davinci-resolve-mcp 2.40.0 → 2.41.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,924 @@
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
+ if str(existing["value_json"]) == value_json and str(existing["source"]) == source:
257
+ continue
258
+ if (
259
+ str(existing["source"]) == HUMAN_SOURCE
260
+ and source != HUMAN_SOURCE
261
+ and not overwrite_human
262
+ ):
263
+ skipped_human += 1
264
+ continue
265
+ conn.execute(
266
+ "UPDATE subjective_fields SET superseded_at = ? WHERE id = ?",
267
+ (now, existing["id"]),
268
+ )
269
+ conn.execute(
270
+ """
271
+ INSERT INTO subjective_fields
272
+ (entity_type, entity_uuid, field_path, value_json, confidence,
273
+ source, author, timestamp)
274
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
275
+ """,
276
+ (entity_type, entity_uuid, field_path, value_json, confidence, source, author, now),
277
+ )
278
+ conn.execute(
279
+ """
280
+ INSERT INTO field_changelog
281
+ (entity_type, entity_uuid, field_path, previous_value_json,
282
+ new_value_json, previous_source, new_source, previous_author,
283
+ new_author, change_reason, timestamp)
284
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
285
+ """,
286
+ (
287
+ entity_type,
288
+ entity_uuid,
289
+ field_path,
290
+ existing["value_json"] if existing is not None else None,
291
+ value_json,
292
+ existing["source"] if existing is not None else None,
293
+ source,
294
+ existing["author"] if existing is not None else None,
295
+ author,
296
+ reason,
297
+ now,
298
+ ),
299
+ )
300
+ written += 1
301
+ return {"written": written, "skipped_human": skipped_human}
302
+
303
+
304
+ def ingest_report(
305
+ project_root: str,
306
+ report: Dict[str, Any],
307
+ *,
308
+ clip_dir: Optional[str] = None,
309
+ author: str = "system",
310
+ source: str = MACHINE_SOURCE,
311
+ ) -> Dict[str, Any]:
312
+ """Write one analysis report into the DB (rows + canonical blob).
313
+
314
+ Idempotent: re-ingesting the same report leaves identical state. Human
315
+ subjective rows are never replaced by machine values.
316
+ """
317
+ if not isinstance(report, dict):
318
+ return {"success": False, "error": "report must be a dict"}
319
+ clip_uuid, aliases = clip_identity(report, clip_dir=clip_dir)
320
+ record = report.get("clip") if isinstance(report.get("clip"), dict) else {}
321
+ visual = report.get("visual") if isinstance(report.get("visual"), dict) else {}
322
+ shot_entries = visual.get("shot_descriptions") if isinstance(visual.get("shot_descriptions"), list) else []
323
+ motion = report.get("motion") if isinstance(report.get("motion"), dict) else {}
324
+ cut_analysis = report.get("cut_analysis") if isinstance(report.get("cut_analysis"), dict) else {}
325
+ transcription = report.get("transcription") if isinstance(report.get("transcription"), dict) else {}
326
+ signature = report.get("analysis_signature") if isinstance(report.get("analysis_signature"), dict) else {}
327
+ profile = report.get("analysis_profile") if isinstance(report.get("analysis_profile"), dict) else {}
328
+ now = _now()
329
+
330
+ with timeline_brain_db.transaction(project_root) as conn:
331
+ existing = conn.execute(
332
+ "SELECT created_at FROM clips WHERE clip_uuid = ?", (clip_uuid,)
333
+ ).fetchone()
334
+ created_at = str(existing["created_at"]) if existing else now
335
+ conn.execute(
336
+ """
337
+ INSERT OR REPLACE INTO clips
338
+ (clip_uuid, clip_dir, resolve_clip_id, media_id, clip_name,
339
+ file_path, bin_path, duration_seconds, fps, resolution,
340
+ media_type, summary, overall_motion_level, cut_count,
341
+ shot_count, analysis_version, depth, signature_hash,
342
+ analyzed_at, vision_status, vision_committed_at,
343
+ created_at, updated_at)
344
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
345
+ """,
346
+ (
347
+ clip_uuid,
348
+ os.path.basename(str(clip_dir).rstrip("/\\")) if clip_dir else None,
349
+ record.get("clip_id"),
350
+ record.get("media_id"),
351
+ record.get("clip_name"),
352
+ record.get("file_path"),
353
+ record.get("bin_path"),
354
+ _duration_seconds(report),
355
+ record.get("fps") if isinstance(record.get("fps"), (int, float)) else None,
356
+ record.get("resolution"),
357
+ record.get("media_type"),
358
+ report.get("summary"),
359
+ motion.get("overall_motion_level"),
360
+ cut_analysis.get("cut_count") if isinstance(cut_analysis.get("cut_count"), int) else None,
361
+ len(shot_entries),
362
+ report.get("analysis_version"),
363
+ profile.get("depth"),
364
+ signature.get("signature_hash"),
365
+ report.get("analyzed_at"),
366
+ report.get("vision_status"),
367
+ report.get("vision_committed_at"),
368
+ created_at,
369
+ now,
370
+ ),
371
+ )
372
+ for alias, kind in aliases:
373
+ conn.execute(
374
+ "INSERT OR IGNORE INTO clip_aliases (alias, clip_uuid, kind) VALUES (?, ?, ?)",
375
+ (alias, clip_uuid, kind),
376
+ )
377
+ conn.execute(
378
+ """
379
+ INSERT OR REPLACE INTO analysis_reports
380
+ (clip_uuid, report_json, signature_hash, analyzed_at, written_at)
381
+ VALUES (?, ?, ?, ?, ?)
382
+ """,
383
+ (clip_uuid, _dumps(report), signature.get("signature_hash"), report.get("analyzed_at"), now),
384
+ )
385
+
386
+ # Shots: rebuild rows, preserving created_at for stable shot_uuids.
387
+ prior_shots = {
388
+ str(r["shot_uuid"]): r
389
+ for r in conn.execute("SELECT * FROM shots WHERE clip_uuid = ?", (clip_uuid,)).fetchall()
390
+ }
391
+ conn.execute("DELETE FROM shots WHERE clip_uuid = ?", (clip_uuid,))
392
+ frame_to_shot: Dict[int, str] = {}
393
+ subj_written = 0
394
+ subj_skipped_human = 0
395
+ for entry in shot_entries:
396
+ if not isinstance(entry, dict):
397
+ continue
398
+ start = entry.get("time_seconds_start")
399
+ end = entry.get("time_seconds_end")
400
+ s_uuid = shot_uuid_for(clip_uuid, start, end)
401
+ prior = prior_shots.get(s_uuid)
402
+ frame_indices = entry.get("frame_indices_used") or entry.get("frame_indices") or []
403
+ if not isinstance(frame_indices, list):
404
+ frame_indices = []
405
+ extra = {
406
+ k: v
407
+ for k, v in entry.items()
408
+ if k not in _SHOT_STRUCTURAL_KEYS and k != "description"
409
+ }
410
+ conn.execute(
411
+ """
412
+ INSERT OR REPLACE INTO shots
413
+ (shot_uuid, clip_uuid, shot_index, time_seconds_start,
414
+ time_seconds_end, description, qc_flags_json,
415
+ frame_indices_json, extra_json, created_at, updated_at)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
417
+ """,
418
+ (
419
+ s_uuid,
420
+ clip_uuid,
421
+ int(entry.get("shot_index") or 0),
422
+ start if isinstance(start, (int, float)) else None,
423
+ end if isinstance(end, (int, float)) else None,
424
+ entry.get("description"),
425
+ _dumps(entry.get("qc_flags") or []),
426
+ _dumps(frame_indices),
427
+ _dumps(extra) if extra else None,
428
+ str(prior["created_at"]) if prior else now,
429
+ now,
430
+ ),
431
+ )
432
+ for raw in frame_indices:
433
+ try:
434
+ frame_to_shot[int(raw)] = s_uuid
435
+ except (TypeError, ValueError):
436
+ continue
437
+ stats = _write_subjective(
438
+ conn, "shot", s_uuid, shot_subjective_fields(entry),
439
+ source=source, author=author,
440
+ )
441
+ subj_written += stats["written"]
442
+ subj_skipped_human += stats["skipped_human"]
443
+
444
+ stats = _write_subjective(
445
+ conn, "clip", clip_uuid, clip_subjective_fields(visual),
446
+ source=source, author=author,
447
+ )
448
+ subj_written += stats["written"]
449
+ subj_skipped_human += stats["skipped_human"]
450
+
451
+ # Transcript segments.
452
+ conn.execute("DELETE FROM transcript_segments WHERE clip_uuid = ?", (clip_uuid,))
453
+ segments = transcription.get("segments") if isinstance(transcription.get("segments"), list) else []
454
+ for idx, seg in enumerate(segments):
455
+ if not isinstance(seg, dict):
456
+ continue
457
+ conn.execute(
458
+ """
459
+ INSERT OR REPLACE INTO transcript_segments
460
+ (clip_uuid, segment_index, start_seconds, end_seconds, text, speaker_id)
461
+ VALUES (?, ?, ?, ?, ?, ?)
462
+ """,
463
+ (
464
+ clip_uuid,
465
+ idx,
466
+ seg.get("start") if isinstance(seg.get("start"), (int, float)) else None,
467
+ seg.get("end") if isinstance(seg.get("end"), (int, float)) else None,
468
+ seg.get("text"),
469
+ seg.get("speaker") or seg.get("speaker_id"),
470
+ ),
471
+ )
472
+
473
+ # Sampled frames.
474
+ conn.execute("DELETE FROM frames WHERE clip_uuid = ?", (clip_uuid,))
475
+ keyframes = motion.get("analysis_keyframes") if isinstance(motion.get("analysis_keyframes"), list) else []
476
+ for kf in keyframes:
477
+ if not isinstance(kf, dict):
478
+ continue
479
+ try:
480
+ frame_index = int(kf.get("index"))
481
+ except (TypeError, ValueError):
482
+ continue
483
+ conn.execute(
484
+ """
485
+ INSERT OR REPLACE INTO frames
486
+ (clip_uuid, shot_uuid, frame_index, time_seconds, frame_path,
487
+ selection_reason, motion_peak)
488
+ VALUES (?, ?, ?, ?, ?, ?, ?)
489
+ """,
490
+ (
491
+ clip_uuid,
492
+ frame_to_shot.get(frame_index),
493
+ frame_index,
494
+ kf.get("time_seconds") if isinstance(kf.get("time_seconds"), (int, float)) else None,
495
+ kf.get("frame_path") or kf.get("path"),
496
+ kf.get("selection_reason"),
497
+ 1 if kf.get("motion_peak") else 0,
498
+ ),
499
+ )
500
+
501
+ # QC observations: machine rows rebuild; human-resolved rows persist.
502
+ conn.execute(
503
+ "DELETE FROM qc_observations WHERE clip_uuid = ? AND resolved = 0 AND source = ?",
504
+ (clip_uuid, source),
505
+ )
506
+ resolved_keys = {
507
+ (str(r["observation_type"]), str(r["message"]))
508
+ for r in conn.execute(
509
+ "SELECT observation_type, message FROM qc_observations WHERE clip_uuid = ? AND resolved = 1",
510
+ (clip_uuid,),
511
+ ).fetchall()
512
+ }
513
+ qc = visual.get("qc") if isinstance(visual.get("qc"), dict) else {}
514
+
515
+ def _insert_qc(obs_type: str, severity: str, message: Any, related: Any = None, confidence: Any = None) -> None:
516
+ msg = str(message or "").strip()
517
+ if not msg or (obs_type, msg) in resolved_keys:
518
+ return
519
+ conn.execute(
520
+ """
521
+ INSERT INTO qc_observations
522
+ (clip_uuid, observation_type, severity, message,
523
+ related_shot_indices_json, confidence, source, created_at)
524
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
525
+ """,
526
+ (
527
+ clip_uuid,
528
+ obs_type,
529
+ severity,
530
+ msg,
531
+ _dumps(related) if related else None,
532
+ str(confidence) if confidence else None,
533
+ source,
534
+ now,
535
+ ),
536
+ )
537
+
538
+ for warning in qc.get("warnings") or []:
539
+ _insert_qc("warning", "warn", warning)
540
+ for obs in qc.get("continuity_observations") or []:
541
+ if isinstance(obs, dict):
542
+ _insert_qc(
543
+ str(obs.get("kind") or "continuity"),
544
+ "info",
545
+ obs.get("observation"),
546
+ related=obs.get("shot_indices"),
547
+ confidence=obs.get("confidence"),
548
+ )
549
+ for gap in qc.get("coverage_gaps") or []:
550
+ _insert_qc("coverage_gap", "info", gap)
551
+
552
+ return {
553
+ "success": True,
554
+ "clip_uuid": clip_uuid,
555
+ "shot_count": len(shot_entries),
556
+ "subjective_fields_written": subj_written,
557
+ "subjective_fields_preserved_human": subj_skipped_human,
558
+ }
559
+
560
+
561
+ # ── export / readers ─────────────────────────────────────────────────────────
562
+
563
+
564
+ def _overlay_human_fields(conn: sqlite3.Connection, clip_uuid: str, report: Dict[str, Any]) -> int:
565
+ """Apply current human subjective rows onto a report dict. Returns count."""
566
+ ma = _ma()
567
+ visual = report.get("visual")
568
+ if not isinstance(visual, dict):
569
+ return 0
570
+ shots_by_uuid: Dict[str, int] = {}
571
+ for row in conn.execute(
572
+ "SELECT shot_uuid, shot_index FROM shots WHERE clip_uuid = ?", (clip_uuid,)
573
+ ).fetchall():
574
+ shots_by_uuid[str(row["shot_uuid"])] = int(row["shot_index"])
575
+ shot_entries = visual.get("shot_descriptions") if isinstance(visual.get("shot_descriptions"), list) else []
576
+ applied = 0
577
+
578
+ rows = conn.execute(
579
+ """
580
+ SELECT entity_type, entity_uuid, field_path, value_json
581
+ FROM subjective_fields
582
+ WHERE superseded_at IS NULL AND source = ?
583
+ AND (
584
+ (entity_type = 'clip' AND entity_uuid = ?)
585
+ OR (entity_type = 'shot' AND entity_uuid IN
586
+ (SELECT shot_uuid FROM shots WHERE clip_uuid = ?))
587
+ )
588
+ """,
589
+ (HUMAN_SOURCE, clip_uuid, clip_uuid),
590
+ ).fetchall()
591
+ for row in rows:
592
+ try:
593
+ value = json.loads(row["value_json"])
594
+ except (TypeError, ValueError):
595
+ continue
596
+ path_parts = [p for p in str(row["field_path"]).split(".") if p]
597
+ if not path_parts:
598
+ continue
599
+ if str(row["entity_type"]) == "clip":
600
+ target: Any = visual
601
+ else:
602
+ shot_index = shots_by_uuid.get(str(row["entity_uuid"]))
603
+ target = None
604
+ if shot_index is not None:
605
+ target = ma._find_shot_entry(shot_entries, str(shot_index))
606
+ if target is None:
607
+ target = ma._find_shot_entry(shot_entries, str(row["entity_uuid"]))
608
+ if target is None:
609
+ continue
610
+ if ma._walk_set(target, path_parts, value):
611
+ applied += 1
612
+ return applied
613
+
614
+
615
+ def export_report(project_root: str, clip_ref: Any) -> Optional[Dict[str, Any]]:
616
+ """Return the canonical report (blob + human overlay) or None if absent."""
617
+ try:
618
+ conn = timeline_brain_db.connect(project_root)
619
+ clip_uuid = resolve_clip_uuid(conn, clip_ref)
620
+ if not clip_uuid:
621
+ return None
622
+ row = conn.execute(
623
+ "SELECT report_json FROM analysis_reports WHERE clip_uuid = ?", (clip_uuid,)
624
+ ).fetchone()
625
+ if not row:
626
+ return None
627
+ report = json.loads(row["report_json"])
628
+ if not isinstance(report, dict):
629
+ return None
630
+ _overlay_human_fields(conn, clip_uuid, report)
631
+ return report
632
+ except (sqlite3.Error, ValueError, OSError) as exc:
633
+ logger.debug("export_report fell back to JSON for %r: %s", clip_ref, exc)
634
+ return None
635
+
636
+
637
+ def load_db_report(
638
+ project_root: str,
639
+ *,
640
+ clip_dir: Optional[str] = None,
641
+ clip_id: Optional[str] = None,
642
+ clip_uuid: Optional[str] = None,
643
+ file_path: Optional[str] = None,
644
+ ) -> Optional[Dict[str, Any]]:
645
+ """DB-first reader used by panel/MCP readers. None means: fall back to JSON."""
646
+ for ref in (clip_uuid, clip_id, clip_dir, file_path):
647
+ if not ref:
648
+ continue
649
+ report = export_report(project_root, ref)
650
+ if report is not None:
651
+ return report
652
+ return None
653
+
654
+
655
+ def export_report_file(project_root: str, clip_ref: Any, path: str) -> Optional[str]:
656
+ """Write the derived analysis.json export for a clip. Returns the path."""
657
+ report = export_report(project_root, clip_ref)
658
+ if report is None:
659
+ return None
660
+ _ma()._write_json(path, report)
661
+ return path
662
+
663
+
664
+ def list_clip_rows(project_root: str) -> List[Dict[str, Any]]:
665
+ """Headline clip rows for list endpoints (no blobs)."""
666
+ conn = timeline_brain_db.connect(project_root)
667
+ rows = conn.execute("SELECT * FROM clips ORDER BY clip_name COLLATE NOCASE").fetchall()
668
+ return [dict(r) for r in rows]
669
+
670
+
671
+ def db_status(project_root: str) -> Dict[str, Any]:
672
+ """Counts + schema version for the analysis store of a project."""
673
+ try:
674
+ conn = timeline_brain_db.connect(project_root)
675
+ version = timeline_brain_db._read_schema_version(conn)
676
+ counts = {}
677
+ for table in (
678
+ "clips",
679
+ "analysis_reports",
680
+ "shots",
681
+ "subjective_fields",
682
+ "transcript_segments",
683
+ "frames",
684
+ "qc_observations",
685
+ ):
686
+ counts[table] = int(conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"])
687
+ human = conn.execute(
688
+ "SELECT COUNT(*) AS n FROM subjective_fields WHERE source = ? AND superseded_at IS NULL",
689
+ (HUMAN_SOURCE,),
690
+ ).fetchone()
691
+ return {
692
+ "success": True,
693
+ "project_root": project_root,
694
+ "db_path": timeline_brain_db.db_path_for_project(project_root),
695
+ "schema_version": version,
696
+ "canonical": version >= 9,
697
+ "counts": counts,
698
+ "current_human_fields": int(human["n"]),
699
+ }
700
+ except (sqlite3.Error, OSError) as exc:
701
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
702
+
703
+
704
+ # ── corrections (row level) ──────────────────────────────────────────────────
705
+
706
+
707
+ def record_human_correction(
708
+ project_root: str,
709
+ *,
710
+ clip_ref: Any,
711
+ entity_type: str,
712
+ entity_uuid: Any,
713
+ field_path: str,
714
+ value: Any,
715
+ author: str = "unknown",
716
+ reason: Optional[str] = None,
717
+ confidence: Optional[str] = None,
718
+ ) -> Dict[str, Any]:
719
+ """Row-level human correction: supersede current value, append changelog.
720
+
721
+ `entity_uuid` accepts a shot_uuid OR a shot_index (resolved against the
722
+ clip's shots), matching the sidecar contract.
723
+ """
724
+ with timeline_brain_db.transaction(project_root) as conn:
725
+ clip_uuid = resolve_clip_uuid(conn, clip_ref)
726
+ if not clip_uuid:
727
+ return {"success": False, "error": f"clip not found in DB: {clip_ref}"}
728
+ target_uuid = clip_uuid
729
+ if entity_type == "shot":
730
+ candidate = str(entity_uuid)
731
+ row = conn.execute(
732
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_uuid = ?",
733
+ (clip_uuid, candidate),
734
+ ).fetchone()
735
+ if row is None:
736
+ try:
737
+ row = conn.execute(
738
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
739
+ (clip_uuid, int(candidate)),
740
+ ).fetchone()
741
+ except (TypeError, ValueError):
742
+ row = None
743
+ if row is None:
744
+ return {"success": False, "error": f"shot not found in DB: {entity_uuid}"}
745
+ target_uuid = str(row["shot_uuid"])
746
+ stats = _write_subjective(
747
+ conn,
748
+ entity_type,
749
+ target_uuid,
750
+ {field_path: value},
751
+ source=HUMAN_SOURCE,
752
+ author=author,
753
+ confidence=confidence,
754
+ reason=reason,
755
+ overwrite_human=True,
756
+ )
757
+ return {
758
+ "success": True,
759
+ "clip_uuid": clip_uuid,
760
+ "entity_type": entity_type,
761
+ "entity_uuid": target_uuid,
762
+ "field_path": field_path,
763
+ "written": stats["written"],
764
+ }
765
+
766
+
767
+ def clear_human_field(
768
+ project_root: str,
769
+ *,
770
+ clip_ref: Any,
771
+ entity_type: str,
772
+ entity_uuid: Any,
773
+ field_path: str,
774
+ author: str = "unknown",
775
+ reason: Optional[str] = None,
776
+ ) -> Dict[str, Any]:
777
+ """Revert a field to machine-derived: supersede the current human row.
778
+
779
+ The machine value lives in the report blob, so superseding the human row
780
+ is all the export overlay needs to fall back to it.
781
+ """
782
+ now = _now()
783
+ with timeline_brain_db.transaction(project_root) as conn:
784
+ clip_uuid = resolve_clip_uuid(conn, clip_ref)
785
+ if not clip_uuid:
786
+ return {"success": False, "error": f"clip not found in DB: {clip_ref}"}
787
+ target_uuid = clip_uuid
788
+ if entity_type == "shot":
789
+ candidate = str(entity_uuid)
790
+ row = conn.execute(
791
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_uuid = ?",
792
+ (clip_uuid, candidate),
793
+ ).fetchone()
794
+ if row is None:
795
+ try:
796
+ row = conn.execute(
797
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
798
+ (clip_uuid, int(candidate)),
799
+ ).fetchone()
800
+ except (TypeError, ValueError):
801
+ row = None
802
+ if row is None:
803
+ return {"success": False, "error": f"shot not found in DB: {entity_uuid}"}
804
+ target_uuid = str(row["shot_uuid"])
805
+ existing = conn.execute(
806
+ """
807
+ SELECT * FROM subjective_fields
808
+ WHERE entity_type = ? AND entity_uuid = ? AND field_path = ?
809
+ AND superseded_at IS NULL AND source = ?
810
+ """,
811
+ (entity_type, target_uuid, field_path, HUMAN_SOURCE),
812
+ ).fetchone()
813
+ if existing is None:
814
+ return {"success": True, "cleared": 0, "note": "no current human row"}
815
+ conn.execute(
816
+ "UPDATE subjective_fields SET superseded_at = ? WHERE id = ?",
817
+ (now, existing["id"]),
818
+ )
819
+ conn.execute(
820
+ """
821
+ INSERT INTO field_changelog
822
+ (entity_type, entity_uuid, field_path, previous_value_json,
823
+ new_value_json, previous_source, new_source, previous_author,
824
+ new_author, change_reason, timestamp)
825
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
826
+ """,
827
+ (
828
+ entity_type,
829
+ target_uuid,
830
+ field_path,
831
+ existing["value_json"],
832
+ _dumps(None),
833
+ existing["source"],
834
+ "machine_revert",
835
+ existing["author"],
836
+ author,
837
+ reason or "reverted to machine-derived",
838
+ now,
839
+ ),
840
+ )
841
+ return {"success": True, "cleared": 1, "clip_uuid": clip_uuid, "entity_uuid": target_uuid}
842
+
843
+
844
+ def _ingest_corrections_sidecar(project_root: str, clip_dir_path: str, clip_uuid: str) -> int:
845
+ """Import human rows from a corrections.json sidecar (db_ingest path)."""
846
+ path = os.path.join(clip_dir_path, "corrections.json")
847
+ if not os.path.isfile(path):
848
+ return 0
849
+ try:
850
+ with open(path, "r", encoding="utf-8") as handle:
851
+ data = json.load(handle)
852
+ except (OSError, json.JSONDecodeError):
853
+ return 0
854
+ current = data.get("current") if isinstance(data.get("current"), dict) else {}
855
+ imported = 0
856
+ for key, entry in current.items():
857
+ if not isinstance(entry, dict) or entry.get("source") != HUMAN_SOURCE:
858
+ continue
859
+ parts = str(key).split(":", 2)
860
+ if len(parts) != 3:
861
+ continue
862
+ entity_type, entity_uuid, field_path = parts
863
+ if entity_type not in ("clip", "shot"):
864
+ continue
865
+ result = record_human_correction(
866
+ project_root,
867
+ clip_ref=clip_uuid,
868
+ entity_type=entity_type,
869
+ entity_uuid=clip_uuid if entity_type == "clip" else entity_uuid,
870
+ field_path=field_path,
871
+ value=entry.get("value"),
872
+ author=str(entry.get("author") or "unknown"),
873
+ reason="imported from corrections.json",
874
+ confidence=entry.get("confidence"),
875
+ )
876
+ if result.get("success") and result.get("written"):
877
+ imported += 1
878
+ return imported
879
+
880
+
881
+ def ingest_project(project_root: str) -> Dict[str, Any]:
882
+ """Walk clips/ and ingest every analysis.json + corrections.json sidecar.
883
+
884
+ The migration entry point for projects analyzed before v9. Idempotent.
885
+ """
886
+ ma = _ma()
887
+ root = ma.normalize_path(project_root)
888
+ clips_root = os.path.join(root, "clips")
889
+ ingested: List[Dict[str, Any]] = []
890
+ errors: List[Dict[str, Any]] = []
891
+ corrections_imported = 0
892
+ if os.path.isdir(clips_root):
893
+ for entry in sorted(os.listdir(clips_root)):
894
+ clip_dir_path = os.path.join(clips_root, entry)
895
+ report_path = os.path.join(clip_dir_path, "analysis.json")
896
+ if not os.path.isfile(report_path):
897
+ continue
898
+ try:
899
+ with open(report_path, "r", encoding="utf-8") as handle:
900
+ report = json.load(handle)
901
+ except (OSError, json.JSONDecodeError) as exc:
902
+ errors.append({"clip_dir": entry, "error": f"{type(exc).__name__}: {exc}"})
903
+ continue
904
+ try:
905
+ result = ingest_report(root, report, clip_dir=clip_dir_path)
906
+ except (sqlite3.Error, ValueError) as exc:
907
+ errors.append({"clip_dir": entry, "error": f"{type(exc).__name__}: {exc}"})
908
+ continue
909
+ if result.get("success"):
910
+ corrections_imported += _ingest_corrections_sidecar(
911
+ root, clip_dir_path, str(result["clip_uuid"])
912
+ )
913
+ ingested.append({"clip_dir": entry, "clip_uuid": result["clip_uuid"]})
914
+ else:
915
+ errors.append({"clip_dir": entry, "error": result.get("error")})
916
+ return {
917
+ "success": not errors,
918
+ "project_root": root,
919
+ "ingested_count": len(ingested),
920
+ "ingested": ingested,
921
+ "corrections_imported": corrections_imported,
922
+ "errors": errors,
923
+ "status": db_status(root),
924
+ }