davinci-resolve-mcp 2.60.0 → 2.61.1

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,663 @@
1
+ """Perception-strata queries — editorial questions as cross-track joins.
2
+
3
+ The strata store (events/curves/words/beats) is the vocabulary; these are
4
+ the sentences: take_diff (compare deliveries of the same line),
5
+ cut_candidates (rank joint frames with reasons), strata_query (windowed
6
+ cross-track fetch, timeline-projectable).
7
+
8
+ The line these never cross: measure, compare, flag, aim — never decide.
9
+ take_diff reports deltas, not a winner; cut_candidates ranks frames with
10
+ evidence, the editor picks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+ import math
17
+ import statistics
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
19
+
20
+ from src.utils import strata, timeline_brain_db
21
+
22
+ _WORD_STRIP = ".,!?;:\"'()[]—–-…"
23
+
24
+
25
+ def _norm_word(word: str) -> str:
26
+ return word.strip().strip(_WORD_STRIP).lower()
27
+
28
+
29
+ def _resolve(project_root: str, clip_ref: Any) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
30
+ _conn, clip, err = strata.resolve_clip(project_root, clip_ref)
31
+ return (clip["clip_uuid"] if clip else None), err
32
+
33
+
34
+ def _finite(values: Sequence[float]) -> List[float]:
35
+ return [v for v in values if isinstance(v, (int, float)) and not math.isnan(v)]
36
+
37
+
38
+ def _curve_slice(curve: Optional[Dict[str, Any]], start: float, end: float) -> List[float]:
39
+ if not curve or not curve.get("values"):
40
+ return []
41
+ rate = float(curve["sample_rate"])
42
+ offset = float(curve.get("start_seconds") or 0.0)
43
+ lo = max(0, int((start - offset) * rate))
44
+ hi = min(len(curve["values"]), int((end - offset) * rate) + 1)
45
+ return curve["values"][lo:hi] if hi > lo else []
46
+
47
+
48
+ def _delivery_metrics(
49
+ conn,
50
+ clip_uuid: str,
51
+ words: List[Dict[str, Any]],
52
+ ) -> Dict[str, Any]:
53
+ """Prosodic profile of one take's aligned words. Measures; no judgment."""
54
+ timed = [w for w in words if isinstance(w.get("start_seconds"), (int, float))]
55
+ if not timed:
56
+ return {"word_count": len(words), "timed": False}
57
+ start = float(timed[0]["start_seconds"])
58
+ last = timed[-1]
59
+ end = float(last.get("end_seconds") or last["start_seconds"])
60
+ duration = max(end - start, 1e-6)
61
+
62
+ pauses = strata.read_events(conn, clip_uuid, "pause", start_seconds=start, end_seconds=end)
63
+ hesitations = strata.read_events(conn, clip_uuid, "hesitation", start_seconds=start, end_seconds=end)
64
+
65
+ metrics: Dict[str, Any] = {
66
+ "timed": True,
67
+ "start_seconds": start,
68
+ "end_seconds": end,
69
+ "duration_seconds": round(duration, 3),
70
+ "word_count": len(words),
71
+ "words_per_second": round(len(timed) / duration, 3),
72
+ "pause_count": len(pauses),
73
+ "pause_total_seconds": round(sum(float(p.get("duration_seconds") or 0.0) for p in pauses), 3),
74
+ "longest_pause_seconds": round(max((float(p.get("duration_seconds") or 0.0) for p in pauses), default=0.0), 3),
75
+ "hesitation_count": len(hesitations),
76
+ }
77
+
78
+ pitch_raw = _curve_slice(strata.read_curve(conn, clip_uuid, "pitch"), start, end)
79
+ pitch = _finite(pitch_raw)
80
+ if len(pitch) >= 4:
81
+ metrics["pitch"] = {
82
+ "mean_hz": round(statistics.fmean(pitch), 1),
83
+ "stdev_hz": round(statistics.pstdev(pitch), 1),
84
+ "range_hz": round(max(pitch) - min(pitch), 1),
85
+ "voiced_ratio": round(len(pitch) / max(1, len(pitch_raw)), 3),
86
+ }
87
+ energy = _finite(_curve_slice(strata.read_curve(conn, clip_uuid, "vocal_energy"), start, end))
88
+ if len(energy) >= 4:
89
+ metrics["energy"] = {
90
+ "mean": round(statistics.fmean(energy), 4),
91
+ "stdev": round(statistics.pstdev(energy), 4),
92
+ "peak": round(max(energy), 4),
93
+ }
94
+ return metrics
95
+
96
+
97
+ def take_diff(
98
+ project_root: str,
99
+ clip_a: Any,
100
+ clip_b: Any,
101
+ *,
102
+ text: Optional[str] = None,
103
+ ) -> Dict[str, Any]:
104
+ """Compare two deliveries of the same (or similar) line, with evidence.
105
+
106
+ Aligns the takes on transcript words (difflib on normalized tokens; when
107
+ `text` is given, each take is first narrowed to its best window matching
108
+ that line). Returns per-take delivery metrics + deltas. It never says
109
+ which take is better — that is the editor's call.
110
+ """
111
+ uuid_a, err = _resolve(project_root, clip_a)
112
+ if err:
113
+ return err
114
+ uuid_b, err = _resolve(project_root, clip_b)
115
+ if err:
116
+ return err
117
+ conn = timeline_brain_db.connect(project_root)
118
+ words_a = strata.read_words(conn, uuid_a)
119
+ words_b = strata.read_words(conn, uuid_b)
120
+ if not words_a or not words_b:
121
+ return {
122
+ "success": False,
123
+ "error": "both takes need transcript_words rows (run transcription / backfill_words first)",
124
+ "words_a": len(words_a),
125
+ "words_b": len(words_b),
126
+ }
127
+
128
+ if text:
129
+ words_a = _best_text_window(words_a, text)
130
+ words_b = _best_text_window(words_b, text)
131
+ if not words_a or not words_b:
132
+ return {"success": False, "error": f"line not found in one of the takes: {text!r}"}
133
+
134
+ tokens_a = [_norm_word(w["word"]) for w in words_a]
135
+ tokens_b = [_norm_word(w["word"]) for w in words_b]
136
+ matcher = difflib.SequenceMatcher(a=tokens_a, b=tokens_b, autojunk=False)
137
+ blocks = [b for b in matcher.get_matching_blocks() if b.size > 0]
138
+ aligned_a = [words_a[b.a + i] for b in blocks for i in range(b.size)]
139
+ aligned_b = [words_b[b.b + i] for b in blocks for i in range(b.size)]
140
+ ratio = matcher.ratio()
141
+
142
+ metrics_a = _delivery_metrics(conn, uuid_a, aligned_a)
143
+ metrics_b = _delivery_metrics(conn, uuid_b, aligned_b)
144
+
145
+ deltas: Dict[str, Any] = {}
146
+ for key in ("duration_seconds", "words_per_second", "pause_count", "pause_total_seconds", "longest_pause_seconds", "hesitation_count"):
147
+ va, vb = metrics_a.get(key), metrics_b.get(key)
148
+ if isinstance(va, (int, float)) and isinstance(vb, (int, float)):
149
+ deltas[key] = round(vb - va, 3)
150
+ if isinstance(metrics_a.get("pitch"), dict) and isinstance(metrics_b.get("pitch"), dict):
151
+ deltas["pitch_mean_hz"] = round(metrics_b["pitch"]["mean_hz"] - metrics_a["pitch"]["mean_hz"], 1)
152
+ deltas["pitch_range_hz"] = round(metrics_b["pitch"]["range_hz"] - metrics_a["pitch"]["range_hz"], 1)
153
+ if isinstance(metrics_a.get("energy"), dict) and isinstance(metrics_b.get("energy"), dict):
154
+ deltas["energy_mean"] = round(metrics_b["energy"]["mean"] - metrics_a["energy"]["mean"], 4)
155
+
156
+ return {
157
+ "success": True,
158
+ "alignment": {
159
+ "ratio": round(ratio, 3),
160
+ "aligned_word_count": len(aligned_a),
161
+ "note": "metrics are computed over the aligned words only",
162
+ },
163
+ "take_a": {"clip_uuid": uuid_a, "text": " ".join(w["word"] for w in aligned_a), **{"metrics": metrics_a}},
164
+ "take_b": {"clip_uuid": uuid_b, "text": " ".join(w["word"] for w in aligned_b), **{"metrics": metrics_b}},
165
+ "deltas_b_minus_a": deltas,
166
+ "judgment": "none — measure/compare only; the pick is the editor's",
167
+ }
168
+
169
+
170
+ # ── strata_query — windowed cross-track fetch + word find + timeline scope ──
171
+
172
+ # Ordering hints only — the tracks actually fetched are whatever exists in the
173
+ # DB for the clip (the vocabulary is open: analyzers and humans may add tracks
174
+ # freely, and every recorded track must be reachable through the query layer).
175
+ _EVENT_TRACK_ORDER = ("pause", "breath", "hesitation", "beat", "downbeat", "blink", "gesture_boundary")
176
+ _CURVE_TRACK_ORDER = ("pitch", "vocal_energy", "speech_rate", "loudness", "motion_energy",
177
+ "gaze_x", "gaze_y", "expression_mouth_open", "expression_brow_raise")
178
+
179
+
180
+ def _present_tracks(conn, clip_uuid: str, table: str, order_hint: Sequence[str]) -> List[str]:
181
+ present = {
182
+ row["track"]
183
+ for row in conn.execute(
184
+ f"SELECT DISTINCT track FROM {table} WHERE clip_uuid = ?", (clip_uuid,)
185
+ ).fetchall()
186
+ }
187
+ ordered = [t for t in order_hint if t in present]
188
+ ordered.extend(sorted(present.difference(order_hint)))
189
+ return ordered
190
+
191
+
192
+ def _clip_bundle(
193
+ conn,
194
+ clip_uuid: str,
195
+ start: Optional[float],
196
+ end: Optional[float],
197
+ *,
198
+ include_curve_values: bool = False,
199
+ cache: Optional[Dict[str, Dict[str, Any]]] = None,
200
+ ) -> Dict[str, Any]:
201
+ """Everything the strata know about one clip window, joined.
202
+
203
+ ``cache`` (keyed by clip_uuid) memoizes the window-independent reads —
204
+ the clips row, present-track lists, and full curve blobs — so callers
205
+ building many bundles for the same clip (word-find hits cluster by clip)
206
+ unpack each curve once. Windowed reads stay per call.
207
+ """
208
+ memo = cache.setdefault(clip_uuid, {}) if cache is not None else {}
209
+ bundle: Dict[str, Any] = {"clip_uuid": clip_uuid}
210
+ if "row" not in memo:
211
+ memo["row"] = conn.execute(
212
+ "SELECT clip_name, duration_seconds, fps FROM clips WHERE clip_uuid = ?", (clip_uuid,)
213
+ ).fetchone()
214
+ row = memo["row"]
215
+ if row:
216
+ bundle["clip_name"] = row["clip_name"]
217
+ bundle["fps"] = row["fps"]
218
+
219
+ bundle["words"] = strata.read_words(conn, clip_uuid, start_seconds=start, end_seconds=end)
220
+
221
+ if "event_tracks" not in memo:
222
+ memo["event_tracks"] = _present_tracks(conn, clip_uuid, "events", _EVENT_TRACK_ORDER)
223
+ events: Dict[str, List[Dict[str, Any]]] = {}
224
+ for track in memo["event_tracks"]:
225
+ hits = strata.read_events(conn, clip_uuid, track, start_seconds=start, end_seconds=end)
226
+ if hits:
227
+ events[track] = hits
228
+ bundle["events"] = events
229
+
230
+ if "curve_tracks" not in memo:
231
+ memo["curve_tracks"] = _present_tracks(conn, clip_uuid, "curves", _CURVE_TRACK_ORDER)
232
+ loaded_curves = memo.setdefault("curves_by_track", {})
233
+ curves: Dict[str, Any] = {}
234
+ for track in memo["curve_tracks"]:
235
+ if track not in loaded_curves:
236
+ loaded_curves[track] = strata.read_curve(conn, clip_uuid, track)
237
+ curve = loaded_curves[track]
238
+ if curve is None:
239
+ continue
240
+ lo = start if start is not None else 0.0
241
+ hi = end if end is not None else (lo + len(curve["values"]) / float(curve["sample_rate"]))
242
+ window = _finite(_curve_slice(curve, lo, hi))
243
+ entry: Dict[str, Any] = {"sample_rate": curve["sample_rate"], "source": curve["source"]}
244
+ if window:
245
+ entry["window_stats"] = {
246
+ "min": round(min(window), 4),
247
+ "max": round(max(window), 4),
248
+ "mean": round(sum(window) / len(window), 4),
249
+ }
250
+ if include_curve_values:
251
+ entry["values"] = _curve_slice(curve, lo, hi)
252
+ entry["start_seconds"] = lo
253
+ curves[track] = entry
254
+ bundle["curves"] = curves
255
+
256
+ beat_sql = "SELECT * FROM story_beats WHERE clip_uuid = ? AND superseded_at IS NULL"
257
+ args: List[Any] = [clip_uuid]
258
+ if start is not None:
259
+ beat_sql += " AND end_seconds > ?"
260
+ args.append(start)
261
+ if end is not None:
262
+ beat_sql += " AND start_seconds < ?"
263
+ args.append(end)
264
+ bundle["story_beats"] = [
265
+ {
266
+ "beat_uuid": r["beat_uuid"],
267
+ "start_seconds": r["start_seconds"],
268
+ "end_seconds": r["end_seconds"],
269
+ "beat_type": r["beat_type"],
270
+ "label": r["label"],
271
+ "summary": r["summary"],
272
+ }
273
+ for r in conn.execute(beat_sql + " ORDER BY start_seconds", args).fetchall()
274
+ ]
275
+ return bundle
276
+
277
+
278
+ def strata_query(
279
+ project_root: str,
280
+ *,
281
+ clip_ref: Any = None,
282
+ start_seconds: Optional[float] = None,
283
+ end_seconds: Optional[float] = None,
284
+ match_word: Optional[str] = None,
285
+ context_seconds: float = 2.0,
286
+ include_curve_values: bool = False,
287
+ limit: int = 20,
288
+ ) -> Dict[str, Any]:
289
+ """The strata as one queryable surface.
290
+
291
+ Modes:
292
+ - clip window: clip_ref (+ start/end) → the full cross-track bundle.
293
+ - word find: match_word (project-wide or within clip_ref) → each hit
294
+ with a ±context_seconds bundle around it — "find the moment", joined.
295
+ """
296
+ conn = timeline_brain_db.connect(project_root)
297
+
298
+ if match_word:
299
+ escaped = match_word.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
300
+ sql = (
301
+ "SELECT w.clip_uuid, w.word, w.start_seconds, w.end_seconds, c.clip_name "
302
+ "FROM transcript_words w JOIN clips c ON c.clip_uuid = w.clip_uuid "
303
+ "WHERE w.word LIKE ? ESCAPE '\\'"
304
+ )
305
+ args: List[Any] = [f"%{escaped}%"]
306
+ if clip_ref is not None:
307
+ uuid_, err = _resolve(project_root, clip_ref)
308
+ if err:
309
+ return err
310
+ sql += " AND w.clip_uuid = ?"
311
+ args.append(uuid_)
312
+ sql += " ORDER BY w.clip_uuid, w.start_seconds LIMIT ?"
313
+ args.append(int(limit))
314
+ hits = []
315
+ bundle_cache: Dict[str, Dict[str, Any]] = {}
316
+ for row in conn.execute(sql, args).fetchall():
317
+ t = row["start_seconds"]
318
+ hit: Dict[str, Any] = {
319
+ "clip_uuid": row["clip_uuid"],
320
+ "clip_name": row["clip_name"],
321
+ "word": row["word"],
322
+ "time_seconds": t,
323
+ }
324
+ if isinstance(t, (int, float)):
325
+ hit["context"] = _clip_bundle(
326
+ conn,
327
+ row["clip_uuid"],
328
+ max(0.0, t - context_seconds),
329
+ t + context_seconds,
330
+ include_curve_values=include_curve_values,
331
+ cache=bundle_cache,
332
+ )
333
+ hits.append(hit)
334
+ return {"success": True, "mode": "word_find", "match": match_word, "hits": hits}
335
+
336
+ if clip_ref is None:
337
+ return {"success": False, "error": "strata_query needs clip_id and/or match_word"}
338
+ uuid_, err = _resolve(project_root, clip_ref)
339
+ if err:
340
+ return err
341
+ return {
342
+ "success": True,
343
+ "mode": "clip_window",
344
+ "start_seconds": start_seconds,
345
+ "end_seconds": end_seconds,
346
+ **_clip_bundle(conn, uuid_, start_seconds, end_seconds, include_curve_values=include_curve_values),
347
+ }
348
+
349
+
350
+ def timeline_strata(
351
+ project_root: str,
352
+ timeline_name: str,
353
+ *,
354
+ timeline_version: Optional[int] = None,
355
+ record_start_frame: Optional[int] = None,
356
+ record_end_frame: Optional[int] = None,
357
+ fps: Optional[float] = None,
358
+ include_curve_values: bool = False,
359
+ ) -> Dict[str, Any]:
360
+ """Project clip strata through a timeline's recorded placements.
361
+
362
+ Reads timeline_clip_usage (snapshotted at archive time), resolves each
363
+ placed media-pool item back to its analyzed clip, and returns per-
364
+ placement strata bundles. Placement rows carry RECORD in/out frames but
365
+ not the source offset, so bundles are whole-clip; exact source↔record
366
+ frame mapping needs the live timeline item and is labeled as such.
367
+
368
+ Frame convention: in_frame/out_frame are ABSOLUTE record frames as
369
+ snapshotted from item.GetStart()/GetEnd() — they include the timeline
370
+ start-timecode offset (e.g. 86400 for a 01:00:00:00 start at 24fps).
371
+ record_start_frame/record_end_frame filters are compared in that same
372
+ absolute space. Snapshots taken at schema v14+ also store the timeline's
373
+ fps and start frame, which are used to add timeline-relative frames and
374
+ seconds per placement; older snapshots omit those derived fields unless
375
+ the caller supplies fps.
376
+ """
377
+ from src.utils import analysis_store
378
+
379
+ conn = timeline_brain_db.connect(project_root)
380
+ if timeline_version is None:
381
+ row = conn.execute(
382
+ "SELECT MAX(timeline_version) AS v FROM timeline_clip_usage WHERE timeline_name = ?",
383
+ (timeline_name,),
384
+ ).fetchone()
385
+ if row is None or row["v"] is None:
386
+ return {
387
+ "success": False,
388
+ "error": f"no clip-usage snapshots recorded for timeline {timeline_name!r}",
389
+ }
390
+ timeline_version = int(row["v"])
391
+
392
+ meta = conn.execute(
393
+ "SELECT fps, start_frame FROM timeline_versions WHERE timeline_name = ? AND version = ?",
394
+ (timeline_name, timeline_version),
395
+ ).fetchone()
396
+ stored_fps = float(meta["fps"]) if meta and meta["fps"] else None
397
+ start_frame = int(meta["start_frame"]) if meta and meta["start_frame"] is not None else None
398
+ fps_source = "caller" if fps is not None else ("snapshot" if stored_fps else None)
399
+ if fps is None:
400
+ fps = stored_fps
401
+ if fps is not None and (not isinstance(fps, (int, float)) or not math.isfinite(fps) or fps <= 0):
402
+ return {"success": False, "error": f"fps must be a positive number, got {fps!r}"}
403
+ fps = float(fps) if fps is not None else None
404
+
405
+ sql = (
406
+ "SELECT * FROM timeline_clip_usage WHERE timeline_name = ? AND timeline_version = ?"
407
+ )
408
+ args: List[Any] = [timeline_name, timeline_version]
409
+ if record_start_frame is not None:
410
+ sql += " AND out_frame > ?"
411
+ args.append(int(record_start_frame))
412
+ if record_end_frame is not None:
413
+ sql += " AND in_frame < ?"
414
+ args.append(int(record_end_frame))
415
+ sql += " ORDER BY track_type, track_index, in_frame"
416
+
417
+ placements = []
418
+ unresolved = []
419
+ # A source clip reused across many cuts resolves and bundles once: the
420
+ # whole-clip bundle (start=end=None) is identical for every placement.
421
+ resolve_memo: Dict[str, Optional[str]] = {}
422
+ bundle_memo: Dict[str, Dict[str, Any]] = {}
423
+ for row in conn.execute(sql, args).fetchall():
424
+ media_id = row["media_pool_item_id"]
425
+ if media_id not in resolve_memo:
426
+ resolve_memo[media_id] = analysis_store.resolve_clip_uuid(conn, media_id)
427
+ clip_uuid = resolve_memo[media_id]
428
+ placement = {
429
+ "media_pool_item_id": media_id,
430
+ "track_type": row["track_type"],
431
+ "track_index": row["track_index"],
432
+ "record_in_frame": row["in_frame"],
433
+ "record_out_frame": row["out_frame"],
434
+ }
435
+ if start_frame is not None:
436
+ placement["timeline_in_frame"] = row["in_frame"] - start_frame
437
+ placement["timeline_out_frame"] = row["out_frame"] - start_frame
438
+ if fps:
439
+ placement["record_in_seconds"] = round((row["in_frame"] - start_frame) / fps, 3)
440
+ placement["record_out_seconds"] = round((row["out_frame"] - start_frame) / fps, 3)
441
+ if clip_uuid:
442
+ if clip_uuid not in bundle_memo:
443
+ bundle_memo[clip_uuid] = _clip_bundle(
444
+ conn, clip_uuid, None, None, include_curve_values=include_curve_values
445
+ )
446
+ placement["strata"] = bundle_memo[clip_uuid]
447
+ else:
448
+ unresolved.append(media_id)
449
+ placements.append(placement)
450
+
451
+ return {
452
+ "success": True,
453
+ "timeline_name": timeline_name,
454
+ "timeline_version": timeline_version,
455
+ "fps": fps,
456
+ "fps_source": fps_source,
457
+ "timeline_start_frame": start_frame,
458
+ "placements": placements,
459
+ "unresolved_media_ids": sorted(set(unresolved)),
460
+ "note": (
461
+ "record_in/out_frame are absolute snapshot frames (start-timecode-inclusive); "
462
+ "record_start/end_frame filters use that same space. Bundles are whole-clip "
463
+ "(clip time); usage snapshots record placement, not source offset — frame-exact "
464
+ "source↔record mapping needs the live item"
465
+ + (
466
+ ""
467
+ if start_frame is not None
468
+ else "; this snapshot predates the v14 timebase columns, so timeline-relative "
469
+ "frames/seconds are unavailable (re-archive to record them)"
470
+ )
471
+ ),
472
+ }
473
+
474
+
475
+ # ── cut_candidates — the joint solver ────────────────────────────────────────
476
+ #
477
+ # Frame-level cut-point grammar, scored from whatever tracks exist:
478
+ # cut on the blink · cut inside movement, not at its poles · don't cut
479
+ # mid-word · don't bisect a breath · pauses are doors · land on the beat.
480
+ # Each feature contributes points AND a human-readable reason; missing
481
+ # tracks are reported, never silently treated as "no signal".
482
+
483
+ _CUT_WEIGHTS = {
484
+ "mid_word": -3.0,
485
+ "word_gap": 1.0,
486
+ "in_pause": 2.0,
487
+ "on_blink": 2.0,
488
+ "bisects_breath": -1.5,
489
+ "clears_breath": 0.75,
490
+ "on_beat": 1.0,
491
+ "in_motion_max": 0.75,
492
+ "dead_still": -0.5,
493
+ }
494
+
495
+ _BLINK_TOLERANCE_FRAMES = 1.5
496
+ _BEAT_TOLERANCE_SECONDS = 0.05
497
+ _BREATH_CLEAR_SECONDS = 0.3
498
+
499
+
500
+ def cut_candidates(
501
+ project_root: str,
502
+ clip_ref: Any,
503
+ time_seconds: float,
504
+ *,
505
+ window_seconds: float = 0.35,
506
+ fps: Optional[float] = None,
507
+ limit: int = 7,
508
+ ) -> Dict[str, Any]:
509
+ """Rank candidate cut frames around an intended joint, with reasons.
510
+
511
+ Scores every frame in ±window_seconds on the cut-point grammar using the
512
+ clip's available strata. Returns ranked candidates; the top row is a
513
+ recommendation to *look at*, not a decision — aim, never decide.
514
+ """
515
+ uuid_, err = _resolve(project_root, clip_ref)
516
+ if err:
517
+ return err
518
+ conn = timeline_brain_db.connect(project_root)
519
+ if fps is None:
520
+ row = conn.execute("SELECT fps, duration_seconds FROM clips WHERE clip_uuid = ?", (uuid_,)).fetchone()
521
+ fps = float(row["fps"]) if row and row["fps"] else 24.0
522
+ if not isinstance(fps, (int, float)) or not math.isfinite(fps) or fps <= 0:
523
+ return {"success": False, "error": f"fps must be a positive number, got {fps!r}"}
524
+ fps = float(fps)
525
+ frame_dur = 1.0 / fps
526
+
527
+ lo = max(0.0, time_seconds - window_seconds)
528
+ hi = time_seconds + window_seconds
529
+ fetch_lo, fetch_hi = lo - 2.0, hi + 2.0
530
+
531
+ words = strata.read_words(conn, uuid_, start_seconds=fetch_lo, end_seconds=fetch_hi)
532
+ pauses = strata.read_events(conn, uuid_, "pause", start_seconds=fetch_lo, end_seconds=fetch_hi)
533
+ breaths = strata.read_events(conn, uuid_, "breath", start_seconds=fetch_lo, end_seconds=fetch_hi)
534
+ blinks = strata.read_events(conn, uuid_, "blink", start_seconds=fetch_lo, end_seconds=fetch_hi)
535
+ beats = strata.read_events(conn, uuid_, "beat", start_seconds=fetch_lo, end_seconds=fetch_hi)
536
+ motion = strata.read_curve(conn, uuid_, "motion_energy")
537
+
538
+ tracks_used = []
539
+ tracks_missing = []
540
+ for name, present in (
541
+ ("transcript_words", bool(words)),
542
+ ("pause", bool(pauses)),
543
+ ("breath", bool(breaths)),
544
+ ("blink", bool(blinks)),
545
+ ("beat", bool(beats)),
546
+ ("motion_energy", motion is not None),
547
+ ):
548
+ (tracks_used if present else tracks_missing).append(name)
549
+
550
+ def score_frame(t: float) -> Tuple[float, List[str]]:
551
+ score = 0.0
552
+ reasons: List[str] = []
553
+
554
+ if words:
555
+ inside = next(
556
+ (
557
+ w for w in words
558
+ if isinstance(w.get("start_seconds"), (int, float))
559
+ and isinstance(w.get("end_seconds"), (int, float))
560
+ and w["start_seconds"] <= t < w["end_seconds"]
561
+ ),
562
+ None,
563
+ )
564
+ if inside:
565
+ score += _CUT_WEIGHTS["mid_word"]
566
+ reasons.append(f"mid-word — bisects “{inside['word']}”")
567
+ else:
568
+ score += _CUT_WEIGHTS["word_gap"]
569
+ reasons.append("between words")
570
+
571
+ for pause in pauses:
572
+ start = float(pause["time_seconds"])
573
+ dur = float(pause.get("duration_seconds") or 0.0)
574
+ if start <= t < start + dur:
575
+ score += _CUT_WEIGHTS["in_pause"]
576
+ reasons.append(f"inside a {dur:.2f}s pause")
577
+ break
578
+
579
+ for blink in blinks:
580
+ if abs(t - float(blink["time_seconds"])) <= _BLINK_TOLERANCE_FRAMES * frame_dur:
581
+ score += _CUT_WEIGHTS["on_blink"]
582
+ reasons.append("lands on a blink")
583
+ break
584
+
585
+ for breath in breaths:
586
+ b_start = float(breath["time_seconds"])
587
+ b_end = b_start + float(breath.get("duration_seconds") or 0.25)
588
+ if b_start <= t < b_end:
589
+ score += _CUT_WEIGHTS["bisects_breath"]
590
+ reasons.append("bisects a breath")
591
+ break
592
+ if 0.0 <= t - b_end <= _BREATH_CLEAR_SECONDS:
593
+ score += _CUT_WEIGHTS["clears_breath"]
594
+ reasons.append("clears the inhale")
595
+ break
596
+
597
+ for beat in beats:
598
+ if abs(t - float(beat["time_seconds"])) <= _BEAT_TOLERANCE_SECONDS:
599
+ score += _CUT_WEIGHTS["on_beat"]
600
+ reasons.append("on the musical beat")
601
+ break
602
+
603
+ if motion is not None:
604
+ v = strata.curve_value_at(motion, t)
605
+ if v is not None:
606
+ if v >= 0.2:
607
+ bonus = _CUT_WEIGHTS["in_motion_max"] * min(v, 1.0)
608
+ score += bonus
609
+ reasons.append(f"inside movement (energy {v:.2f})")
610
+ elif v < 0.05:
611
+ score += _CUT_WEIGHTS["dead_still"]
612
+ reasons.append("dead stillness")
613
+
614
+ return score, reasons
615
+
616
+ candidates = []
617
+ n_frames = int(round((hi - lo) / frame_dur)) + 1
618
+ for i in range(n_frames):
619
+ t = lo + i * frame_dur
620
+ score, reasons = score_frame(t)
621
+ candidates.append(
622
+ {
623
+ "time_seconds": round(t, 4),
624
+ "frame_offset": int(round((t - time_seconds) * fps)),
625
+ "score": round(score, 3),
626
+ "reasons": reasons,
627
+ }
628
+ )
629
+ candidates.sort(key=lambda c: (-c["score"], abs(c["frame_offset"])))
630
+
631
+ return {
632
+ "success": True,
633
+ "clip_uuid": uuid_,
634
+ "requested_time_seconds": time_seconds,
635
+ "fps": fps,
636
+ "window_seconds": window_seconds,
637
+ "candidates": candidates[: max(1, limit)],
638
+ "tracks_used": tracks_used,
639
+ "tracks_missing": tracks_missing,
640
+ "note": "ranked evidence, not a decision — the editor picks the frame",
641
+ }
642
+
643
+
644
+ def _best_text_window(words: List[Dict[str, Any]], text: str) -> List[Dict[str, Any]]:
645
+ """Narrow a take's words to the window best matching `text`."""
646
+ target = [_norm_word(t) for t in text.split() if _norm_word(t)]
647
+ if not target:
648
+ return words
649
+ tokens = [_norm_word(w["word"]) for w in words]
650
+ n = len(target)
651
+ if len(tokens) <= n:
652
+ return words
653
+ best_start, best_score = 0, -1.0
654
+ # Slide a window of the target length (± slack) and score similarity.
655
+ for start in range(0, len(tokens) - 1):
656
+ window = tokens[start:start + n + max(2, n // 3)]
657
+ score = difflib.SequenceMatcher(a=target, b=window, autojunk=False).ratio()
658
+ if score > best_score:
659
+ best_score, best_start = score, start
660
+ if best_score < 0.4:
661
+ return []
662
+ end = min(len(words), best_start + n + max(2, n // 3))
663
+ return words[best_start:end]