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.
@@ -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
+ }