davinci-resolve-mcp 2.44.0 → 2.45.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,646 @@
1
+ """Edit-engine planning layer (Phase E of the analysis program).
2
+
3
+ Pure evidence + planning: this module reads the DB-canonical analysis store
4
+ and produces dry-run plans with a per-decision rationale. It never imports
5
+ or touches Resolve — execution (timeline creation, lifts, swaps) lives in
6
+ server.py behind the confirm-token gate and the destructive hook, which
7
+ supplies versioning + brain_edits for free.
8
+
9
+ Plans persist under ``memory/edit_plans/<plan_id>.json`` with a content
10
+ fingerprint; execution revalidates the fingerprint so a stale plan cannot
11
+ run against a changed project.
12
+
13
+ Loops:
14
+ - E1 selects — rank shots by select potential / best moments (deep-tier
15
+ subjective rows, with description fallbacks), story-spine order, build a
16
+ NEW selects timeline (additive; failure costs nothing).
17
+ - E2 tighten — find dead air (transcript gaps within each timeline item's
18
+ source range) and propose lifts toward a stated goal, applied to a
19
+ DUPLICATE of the timeline, never the original.
20
+ - E3 swap — rank alternate shots for a timeline item via the
21
+ embeddings similarity index.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import time
30
+ import uuid
31
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
32
+
33
+ from src.utils import analysis_memory, analysis_store, timeline_brain_db
34
+
35
+ PLAN_DIR_NAME = "edit_plans"
36
+ DEFAULT_HANDLE_SECONDS = 0.25
37
+ DEFAULT_MIN_PAUSE_SECONDS = 1.5
38
+
39
+ _SELECT_RANK = {"high": 3, "medium": 2, "low": 1}
40
+
41
+
42
+ def _now() -> str:
43
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
44
+
45
+
46
+ def _plan_dir(project_root: str) -> str:
47
+ return os.path.join(analysis_memory.memory_dir(project_root), PLAN_DIR_NAME)
48
+
49
+
50
+ def _plan_fingerprint(plan: Dict[str, Any]) -> str:
51
+ body = {k: v for k, v in plan.items() if k not in ("fingerprint", "saved_at")}
52
+ return hashlib.sha256(json.dumps(body, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16]
53
+
54
+
55
+ def save_plan(project_root: str, plan: Dict[str, Any]) -> Dict[str, Any]:
56
+ analysis_memory.ensure_memory_structure(project_root)
57
+ os.makedirs(_plan_dir(project_root), exist_ok=True)
58
+ plan = dict(plan)
59
+ plan.setdefault("plan_id", uuid.uuid4().hex[:12])
60
+ plan["saved_at"] = _now()
61
+ plan["fingerprint"] = _plan_fingerprint(plan)
62
+ path = os.path.join(_plan_dir(project_root), f"{plan['plan_id']}.json")
63
+ tmp = path + ".tmp"
64
+ with open(tmp, "w", encoding="utf-8") as handle:
65
+ json.dump(plan, handle, indent=2, default=str)
66
+ os.replace(tmp, path)
67
+ return plan
68
+
69
+
70
+ def load_plan(project_root: str, plan_id: str) -> Optional[Dict[str, Any]]:
71
+ path = os.path.join(_plan_dir(project_root), f"{str(plan_id)}.json")
72
+ try:
73
+ with open(path, "r", encoding="utf-8") as handle:
74
+ plan = json.load(handle)
75
+ except (OSError, json.JSONDecodeError):
76
+ return None
77
+ if not isinstance(plan, dict):
78
+ return None
79
+ if plan.get("fingerprint") != _plan_fingerprint(plan):
80
+ return {"_corrupt": True, "plan_id": plan_id}
81
+ return plan
82
+
83
+
84
+ def list_plans(project_root: str, *, limit: int = 20) -> Dict[str, Any]:
85
+ directory = _plan_dir(project_root)
86
+ rows: List[Dict[str, Any]] = []
87
+ if os.path.isdir(directory):
88
+ for name in sorted(os.listdir(directory), reverse=True):
89
+ if not name.endswith(".json"):
90
+ continue
91
+ plan = load_plan(project_root, name[:-5])
92
+ if not plan or plan.get("_corrupt"):
93
+ continue
94
+ rows.append({
95
+ "plan_id": plan.get("plan_id"),
96
+ "kind": plan.get("kind"),
97
+ "saved_at": plan.get("saved_at"),
98
+ "executed_at": plan.get("executed_at"),
99
+ "summary": plan.get("summary"),
100
+ })
101
+ rows.sort(key=lambda r: str(r.get("saved_at") or ""), reverse=True)
102
+ return {"success": True, "plans": rows[: max(1, int(limit))]}
103
+
104
+
105
+ def mark_plan_executed(project_root: str, plan_id: str, result_summary: Dict[str, Any]) -> None:
106
+ plan = load_plan(project_root, plan_id)
107
+ if not plan or plan.get("_corrupt"):
108
+ return
109
+ plan["executed_at"] = _now()
110
+ plan["execution_summary"] = result_summary
111
+ plan["fingerprint"] = _plan_fingerprint(plan)
112
+ path = os.path.join(_plan_dir(project_root), f"{plan_id}.json")
113
+ tmp = path + ".tmp"
114
+ with open(tmp, "w", encoding="utf-8") as handle:
115
+ json.dump(plan, handle, indent=2, default=str)
116
+ os.replace(tmp, path)
117
+
118
+
119
+ # ── shared evidence helpers ──────────────────────────────────────────────────
120
+
121
+
122
+ def _shot_groups(shot_row: Dict[str, Any]) -> Dict[str, Any]:
123
+ extra = shot_row.get("extra_json")
124
+ if not extra:
125
+ return {}
126
+ try:
127
+ groups = json.loads(extra)
128
+ except (TypeError, ValueError):
129
+ return {}
130
+ return groups if isinstance(groups, dict) else {}
131
+
132
+
133
+ def _clip_fps(clip_row: Dict[str, Any]) -> float:
134
+ fps = clip_row.get("fps")
135
+ try:
136
+ fps = float(fps)
137
+ except (TypeError, ValueError):
138
+ fps = 0.0
139
+ return fps if fps > 0 else 24.0
140
+
141
+
142
+ # ── E1: selects assembly ─────────────────────────────────────────────────────
143
+
144
+
145
+ def plan_selects(
146
+ project_root: str,
147
+ *,
148
+ timeline_name: Optional[str] = None,
149
+ max_duration_seconds: Optional[float] = None,
150
+ min_select_potential: str = "medium",
151
+ handle_seconds: float = DEFAULT_HANDLE_SECONDS,
152
+ max_shots: int = 60,
153
+ ) -> Dict[str, Any]:
154
+ """Rank shots into a selects plan (story-spine order, additive)."""
155
+ conn = timeline_brain_db.connect(project_root)
156
+ clips = {str(r["clip_uuid"]): dict(r) for r in conn.execute(
157
+ "SELECT * FROM clips ORDER BY clip_name COLLATE NOCASE"
158
+ ).fetchall()}
159
+ if not clips:
160
+ return {"success": False, "error": "No analyzed clips in the DB — analyze (or db_ingest) first."}
161
+ min_rank = _SELECT_RANK.get(str(min_select_potential).lower(), 2)
162
+
163
+ candidates: List[Dict[str, Any]] = []
164
+ clip_order = {uuid_: i for i, uuid_ in enumerate(clips)}
165
+ for shot_row in conn.execute(
166
+ "SELECT * FROM shots ORDER BY clip_uuid, shot_index"
167
+ ).fetchall():
168
+ shot = dict(shot_row)
169
+ clip = clips.get(str(shot["clip_uuid"]))
170
+ if not clip or not clip.get("resolve_clip_id"):
171
+ continue
172
+ start = shot.get("time_seconds_start")
173
+ end = shot.get("time_seconds_end")
174
+ if start is None or end is None or float(end) - float(start) < 0.4:
175
+ continue
176
+ groups = _shot_groups(shot)
177
+ editorial = groups.get("editorial") if isinstance(groups.get("editorial"), dict) else {}
178
+ select_potential = str(editorial.get("select_potential") or "").lower()
179
+ best_moment = editorial.get("best_moment") if isinstance(editorial.get("best_moment"), dict) else None
180
+ rank = _SELECT_RANK.get(select_potential, 0)
181
+ evidence: List[str] = []
182
+ if rank:
183
+ evidence.append(f"editorial.select_potential={select_potential} (deep vision)")
184
+ if best_moment:
185
+ evidence.append(f"best_moment at {best_moment.get('time_seconds')}s: {best_moment.get('why')}")
186
+ if rank == 0:
187
+ # Standard-analyzed clips have no deep editorial fields — fall back
188
+ # to clip-level select potential so E1 works day one.
189
+ clip_sp = conn.execute(
190
+ """
191
+ SELECT value_json FROM subjective_fields
192
+ WHERE entity_type='clip' AND entity_uuid=? AND superseded_at IS NULL
193
+ AND field_path='editorial_classification.select_potential'
194
+ """,
195
+ (shot["clip_uuid"],),
196
+ ).fetchone()
197
+ if clip_sp:
198
+ try:
199
+ value = str(json.loads(clip_sp["value_json"])).lower()
200
+ rank = _SELECT_RANK.get(value, 0)
201
+ if rank:
202
+ evidence.append(f"clip-level select_potential={value} (no per-shot deep pass yet)")
203
+ except (TypeError, ValueError):
204
+ pass
205
+ if rank < min_rank:
206
+ continue
207
+ candidates.append({
208
+ "clip_uuid": shot["clip_uuid"],
209
+ "clip_name": clip.get("clip_name"),
210
+ "resolve_clip_id": clip.get("resolve_clip_id"),
211
+ "shot_uuid": shot["shot_uuid"],
212
+ "shot_index": shot["shot_index"],
213
+ "time_seconds_start": float(start),
214
+ "time_seconds_end": float(end),
215
+ "duration_seconds": round(float(end) - float(start), 3),
216
+ "fps": _clip_fps(clip),
217
+ "rank": rank,
218
+ "description": shot.get("description"),
219
+ "rationale": "; ".join(evidence) or "shot present in analysis",
220
+ "_order": (clip_order[str(shot["clip_uuid"])], int(shot["shot_index"])),
221
+ })
222
+
223
+ if not candidates:
224
+ return {
225
+ "success": False,
226
+ "error": (
227
+ f"No shots at select_potential >= {min_select_potential}. Run a deep pass "
228
+ "(media_analysis action='deepen') or lower min_select_potential."
229
+ ),
230
+ }
231
+
232
+ # Highest rank wins the budget; story-spine order for the final sequence.
233
+ candidates.sort(key=lambda c: (-c["rank"], c["_order"]))
234
+ chosen: List[Dict[str, Any]] = []
235
+ total = 0.0
236
+ for candidate in candidates[: max(1, int(max_shots) * 3)]:
237
+ duration = candidate["duration_seconds"] + 2 * float(handle_seconds)
238
+ if max_duration_seconds and total + duration > float(max_duration_seconds) and chosen:
239
+ continue
240
+ chosen.append(candidate)
241
+ total += duration
242
+ if len(chosen) >= int(max_shots):
243
+ break
244
+ chosen.sort(key=lambda c: c["_order"])
245
+
246
+ decisions = []
247
+ clip_infos = []
248
+ for candidate in chosen:
249
+ fps = candidate["fps"]
250
+ clip_row = clips.get(str(candidate["clip_uuid"])) or {}
251
+ clip_duration = clip_row.get("duration_seconds")
252
+ src_start = max(0.0, candidate["time_seconds_start"] - float(handle_seconds))
253
+ src_end = candidate["time_seconds_end"] + float(handle_seconds)
254
+ if isinstance(clip_duration, (int, float)) and clip_duration:
255
+ src_end = min(src_end, float(clip_duration))
256
+ start_frame = int(round(src_start * fps))
257
+ end_frame = max(start_frame + 1, int(round(src_end * fps)) - 1)
258
+ decision = {k: v for k, v in candidate.items() if not k.startswith("_")}
259
+ decision["source_frame_range"] = [start_frame, end_frame]
260
+ decisions.append(decision)
261
+ clip_infos.append({
262
+ "clip_id": candidate["resolve_clip_id"],
263
+ "start_frame": start_frame,
264
+ "end_frame": end_frame,
265
+ })
266
+
267
+ name = timeline_name or f"Selects — {_now()[:10]}"
268
+ plan = save_plan(project_root, {
269
+ "kind": "selects",
270
+ "timeline_name": name,
271
+ "decisions": decisions,
272
+ "clip_infos": clip_infos,
273
+ "estimated_duration_seconds": round(total, 2),
274
+ "summary": f"{len(decisions)} shots, ~{round(total, 1)}s → new timeline '{name}'",
275
+ "settings": {
276
+ "min_select_potential": min_select_potential,
277
+ "max_duration_seconds": max_duration_seconds,
278
+ "handle_seconds": handle_seconds,
279
+ },
280
+ })
281
+ return {
282
+ "success": True,
283
+ "status": "plan_ready",
284
+ "plan_id": plan["plan_id"],
285
+ "kind": "selects",
286
+ "timeline_name": name,
287
+ "decision_count": len(decisions),
288
+ "estimated_duration_seconds": plan["estimated_duration_seconds"],
289
+ "decisions": decisions,
290
+ "note": (
291
+ "Dry-run plan. Execute with edit_engine(action='execute_selects', "
292
+ "params={plan_id}) — a NEW timeline is created; nothing existing is touched."
293
+ ),
294
+ }
295
+
296
+
297
+ # ── E2: tighten ──────────────────────────────────────────────────────────────
298
+
299
+
300
+ def _speech_intervals(conn, clip_uuid: str) -> List[Tuple[float, float]]:
301
+ rows = conn.execute(
302
+ """
303
+ SELECT start_seconds, end_seconds FROM transcript_segments
304
+ WHERE clip_uuid = ? AND start_seconds IS NOT NULL AND end_seconds IS NOT NULL
305
+ ORDER BY start_seconds
306
+ """,
307
+ (clip_uuid,),
308
+ ).fetchall()
309
+ return [(float(r["start_seconds"]), float(r["end_seconds"])) for r in rows]
310
+
311
+
312
+ def _gaps_in_range(
313
+ intervals: Sequence[Tuple[float, float]],
314
+ start: float,
315
+ end: float,
316
+ *,
317
+ min_gap: float,
318
+ ) -> List[Tuple[float, float]]:
319
+ """Sub-ranges of [start, end] not covered by any interval, >= min_gap."""
320
+ gaps: List[Tuple[float, float]] = []
321
+ cursor = start
322
+ for s, e in sorted(intervals):
323
+ if e <= start or s >= end:
324
+ continue
325
+ s, e = max(s, start), min(e, end)
326
+ if s - cursor >= min_gap:
327
+ gaps.append((cursor, s))
328
+ cursor = max(cursor, e)
329
+ if end - cursor >= min_gap:
330
+ gaps.append((cursor, end))
331
+ return gaps
332
+
333
+
334
+ def plan_tighten(
335
+ project_root: str,
336
+ *,
337
+ items: Sequence[Dict[str, Any]],
338
+ timeline_name: str,
339
+ timeline_fps: float,
340
+ target_ratio: Optional[float] = None,
341
+ min_pause_seconds: float = DEFAULT_MIN_PAUSE_SECONDS,
342
+ handle_seconds: float = DEFAULT_HANDLE_SECONDS,
343
+ ) -> Dict[str, Any]:
344
+ """Propose dead-air lifts for a timeline.
345
+
346
+ `items` rows come from the server (Resolve read): each needs
347
+ {timeline_start_frame, timeline_end_frame, source_start_frame,
348
+ media_ref (clip id / path / hash), item_name?}. Lifts are returned in
349
+ timeline frames, latest-first ready.
350
+ """
351
+ if not items:
352
+ return {"success": False, "error": "No timeline items supplied"}
353
+ fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
354
+ conn = timeline_brain_db.connect(project_root)
355
+
356
+ lifts: List[Dict[str, Any]] = []
357
+ skipped: List[Dict[str, Any]] = []
358
+ item_specs: List[Dict[str, Any]] = [] # per usable item: source mapping for keep-range rebuild
359
+ timeline_total_frames = 0
360
+ for item_index, item in enumerate(items):
361
+ try:
362
+ tl_start = int(item["timeline_start_frame"])
363
+ tl_end = int(item["timeline_end_frame"])
364
+ src_start_frame = int(item.get("source_start_frame") or 0)
365
+ except (KeyError, TypeError, ValueError):
366
+ skipped.append({"item": item.get("item_name"), "reason": "missing frame fields"})
367
+ continue
368
+ timeline_total_frames += max(0, tl_end - tl_start)
369
+ clip_uuid = analysis_store.resolve_clip_uuid(
370
+ conn, item.get("media_ref")
371
+ ) or analysis_store.resolve_clip_uuid(conn, item.get("media_path"))
372
+ if not clip_uuid:
373
+ skipped.append({"item": item.get("item_name"), "reason": "no analysis for source media (db_ingest or analyze first)"})
374
+ item_specs.append({"item_index": item_index, "unanalyzed": True,
375
+ "resolve_clip_id": None, "item": item})
376
+ continue
377
+ clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
378
+ clip_fps = _clip_fps(dict(clip_row)) if clip_row else fps
379
+ src_start_sec = src_start_frame / clip_fps
380
+ src_end_sec = src_start_sec + (tl_end - tl_start) / fps
381
+ spec = {
382
+ "item_index": item_index,
383
+ "item": item,
384
+ "clip_uuid": clip_uuid,
385
+ "clip_fps": clip_fps,
386
+ "resolve_clip_id": dict(clip_row).get("resolve_clip_id") if clip_row else None,
387
+ "src_start_sec": src_start_sec,
388
+ "src_end_sec": src_end_sec,
389
+ }
390
+ item_specs.append(spec)
391
+ speech = _speech_intervals(conn, clip_uuid)
392
+ if not speech:
393
+ skipped.append({"item": item.get("item_name"), "reason": "no transcript segments — dead-air evidence unavailable"})
394
+ continue
395
+ for gap_start, gap_end in _gaps_in_range(speech, src_start_sec, src_end_sec, min_gap=float(min_pause_seconds)):
396
+ # Keep handles on both sides of the lift.
397
+ lift_start_sec = gap_start + float(handle_seconds)
398
+ lift_end_sec = gap_end - float(handle_seconds)
399
+ if lift_end_sec - lift_start_sec < 0.2:
400
+ continue
401
+ lift_start = tl_start + int(round((lift_start_sec - src_start_sec) * fps))
402
+ lift_end = tl_start + int(round((lift_end_sec - src_start_sec) * fps))
403
+ if lift_end <= lift_start:
404
+ continue
405
+ lifts.append({
406
+ "kind": "dead_air",
407
+ "action": "lift",
408
+ "timeline_start_frame": lift_start,
409
+ "timeline_end_frame": lift_end,
410
+ "duration_seconds": round((lift_end - lift_start) / fps, 3),
411
+ "item_name": item.get("item_name"),
412
+ "item_index": item_index,
413
+ "clip_uuid": clip_uuid,
414
+ "source_lift_seconds": [round(lift_start_sec, 3), round(lift_end_sec, 3)],
415
+ "rationale": (
416
+ f"No speech from {round(gap_start, 2)}s to {round(gap_end, 2)}s in the source "
417
+ f"transcript ({round(gap_end - gap_start, 2)}s pause; handles kept)."
418
+ ),
419
+ "evidence": {
420
+ "source_gap_seconds": [round(gap_start, 3), round(gap_end, 3)],
421
+ "basis": "transcript_segments",
422
+ },
423
+ })
424
+
425
+ if not lifts:
426
+ return {
427
+ "success": False,
428
+ "error": "No dead-air lifts found",
429
+ "skipped": skipped,
430
+ "note": f"min_pause_seconds={min_pause_seconds}; items without transcripts are skipped.",
431
+ }
432
+
433
+ lifts.sort(key=lambda l: -l["duration_seconds"])
434
+ if target_ratio:
435
+ target_frames = timeline_total_frames * float(target_ratio)
436
+ chosen: List[Dict[str, Any]] = []
437
+ removed = 0.0
438
+ for lift in lifts:
439
+ if removed >= target_frames:
440
+ break
441
+ chosen.append(lift)
442
+ removed += (lift["timeline_end_frame"] - lift["timeline_start_frame"])
443
+ lifts = chosen
444
+ # Latest-first application order so earlier spans stay valid.
445
+ lifts.sort(key=lambda l: -l["timeline_start_frame"])
446
+
447
+ # Keep ranges: per item, the complement of its selected lifts, expressed as
448
+ # media-pool SOURCE frame ranges. Execution assembles a tightened VARIANT
449
+ # timeline from these (true partial trims; the original is never mutated).
450
+ keep_ranges: List[Dict[str, Any]] = []
451
+ lifts_by_item: Dict[int, List[Dict[str, Any]]] = {}
452
+ for lift in lifts:
453
+ lifts_by_item.setdefault(int(lift["item_index"]), []).append(lift)
454
+ for spec in item_specs:
455
+ item = spec["item"]
456
+ if spec.get("unanalyzed") or not spec.get("resolve_clip_id"):
457
+ # Items we can't trim ride along whole when their clip is known;
458
+ # otherwise they were already reported in `skipped`.
459
+ continue
460
+ clip_fps = spec["clip_fps"]
461
+ cursor = spec["src_start_sec"]
462
+ segments: List[Tuple[float, float]] = []
463
+ for lift in sorted(lifts_by_item.get(spec["item_index"], []), key=lambda l: l["source_lift_seconds"][0]):
464
+ lift_start_sec, lift_end_sec = lift["source_lift_seconds"]
465
+ if lift_start_sec - cursor > 0.05:
466
+ segments.append((cursor, lift_start_sec))
467
+ cursor = max(cursor, lift_end_sec)
468
+ if spec["src_end_sec"] - cursor > 0.05:
469
+ segments.append((cursor, spec["src_end_sec"]))
470
+ for seg_start, seg_end in segments:
471
+ start_frame = int(round(seg_start * clip_fps))
472
+ end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
473
+ keep_ranges.append({
474
+ "clip_id": spec["resolve_clip_id"],
475
+ "start_frame": start_frame,
476
+ "end_frame": end_frame,
477
+ "track_type": "video",
478
+ "track_index": int(item.get("track_index") or 1),
479
+ })
480
+
481
+ removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
482
+ plan = save_plan(project_root, {
483
+ "kind": "tighten",
484
+ "timeline_name": timeline_name,
485
+ "timeline_fps": fps,
486
+ "lifts": lifts,
487
+ "keep_ranges": keep_ranges,
488
+ "skipped": skipped,
489
+ "summary": (
490
+ f"{len(lifts)} dead-air lifts, ~{round(removed_frames / fps, 1)}s removed "
491
+ f"from '{timeline_name}' (assembled as a tightened variant)"
492
+ ),
493
+ "settings": {
494
+ "target_ratio": target_ratio,
495
+ "min_pause_seconds": min_pause_seconds,
496
+ "handle_seconds": handle_seconds,
497
+ },
498
+ })
499
+ return {
500
+ "success": True,
501
+ "status": "plan_ready",
502
+ "plan_id": plan["plan_id"],
503
+ "kind": "tighten",
504
+ "timeline_name": timeline_name,
505
+ "lift_count": len(lifts),
506
+ "estimated_removed_seconds": round(removed_frames / fps, 2),
507
+ "lifts": lifts,
508
+ "keep_range_count": len(keep_ranges),
509
+ "skipped": skipped,
510
+ "note": (
511
+ "Dry-run plan. Execute with edit_engine(action='execute_tighten', "
512
+ "params={plan_id}) — a tightened VARIANT timeline is assembled from "
513
+ "the keep ranges; the original timeline is never mutated."
514
+ ),
515
+ }
516
+
517
+
518
+ # ── E3: swap alternates ──────────────────────────────────────────────────────
519
+
520
+
521
+ def plan_swap(
522
+ project_root: str,
523
+ *,
524
+ item: Dict[str, Any],
525
+ timeline_name: str,
526
+ timeline_fps: float,
527
+ kind: str = "visual",
528
+ limit: int = 5,
529
+ ) -> Dict[str, Any]:
530
+ """Rank alternate shots for one timeline item via the similarity index.
531
+
532
+ `item` comes from the server: {timeline_start_frame, timeline_end_frame,
533
+ source_start_frame, media_ref, item_name?, track_index?}.
534
+ """
535
+ from src.utils import embeddings
536
+
537
+ conn = timeline_brain_db.connect(project_root)
538
+ clip_uuid = analysis_store.resolve_clip_uuid(
539
+ conn, item.get("media_ref")
540
+ ) or analysis_store.resolve_clip_uuid(conn, item.get("media_path"))
541
+ if not clip_uuid:
542
+ return {"success": False, "error": "No analysis for the item's source media (db_ingest or analyze first)"}
543
+ clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
544
+ clip_fps = _clip_fps(dict(clip_row)) if clip_row else 24.0
545
+ fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
546
+ try:
547
+ tl_start = int(item["timeline_start_frame"])
548
+ tl_end = int(item["timeline_end_frame"])
549
+ src_start_frame = int(item.get("source_start_frame") or 0)
550
+ except (KeyError, TypeError, ValueError):
551
+ return {"success": False, "error": "item requires timeline_start_frame/timeline_end_frame/source_start_frame"}
552
+ src_mid_sec = src_start_frame / clip_fps + ((tl_end - tl_start) / fps) / 2.0
553
+ shot_row = conn.execute(
554
+ """
555
+ SELECT * FROM shots
556
+ WHERE clip_uuid = ? AND time_seconds_start <= ? AND time_seconds_end > ?
557
+ """,
558
+ (clip_uuid, src_mid_sec, src_mid_sec),
559
+ ).fetchone()
560
+ if not shot_row:
561
+ return {"success": False, "error": f"No analyzed shot covers source time {round(src_mid_sec, 2)}s"}
562
+ shot = dict(shot_row)
563
+
564
+ found = embeddings.find_similar(
565
+ project_root,
566
+ shot_uuid=shot["shot_uuid"],
567
+ kind=kind,
568
+ entity_types=["shot"],
569
+ limit=int(limit) * 2,
570
+ )
571
+ if not found.get("success"):
572
+ return found
573
+ duration_frames = tl_end - tl_start
574
+ alternates: List[Dict[str, Any]] = []
575
+ for hit in found.get("results") or []:
576
+ alt_clip = conn.execute(
577
+ "SELECT * FROM clips WHERE clip_uuid = ?", (hit.get("clip_uuid"),)
578
+ ).fetchone()
579
+ if not alt_clip or not alt_clip["resolve_clip_id"]:
580
+ continue
581
+ alt = dict(alt_clip)
582
+ alt_fps = _clip_fps(alt)
583
+ alt_start = hit.get("time_seconds_start")
584
+ alt_end = hit.get("time_seconds_end")
585
+ if alt_start is None or alt_end is None:
586
+ continue
587
+ needed_seconds = duration_frames / fps
588
+ if (float(alt_end) - float(alt_start)) < needed_seconds:
589
+ continue # alternate too short to fill the slot
590
+ start_frame = int(round(float(alt_start) * alt_fps))
591
+ end_frame = start_frame + int(round(needed_seconds * alt_fps)) - 1
592
+ alternates.append({
593
+ "score": hit.get("score"),
594
+ "clip_uuid": hit.get("clip_uuid"),
595
+ "clip_name": alt.get("clip_name"),
596
+ "resolve_clip_id": alt["resolve_clip_id"],
597
+ "shot_uuid": hit.get("entity_uuid"),
598
+ "shot_index": hit.get("shot_index"),
599
+ "description": hit.get("description"),
600
+ "source_frame_range": [start_frame, end_frame],
601
+ "rationale": f"cosine {hit.get('score')} to the current shot ({kind} embedding); long enough to fill the slot exactly",
602
+ })
603
+ if len(alternates) >= int(limit):
604
+ break
605
+ if not alternates:
606
+ return {
607
+ "success": False,
608
+ "error": "No viable alternates (similar shots were too short or their clips are not in this Resolve project)",
609
+ }
610
+
611
+ plan = save_plan(project_root, {
612
+ "kind": "swap",
613
+ "timeline_name": timeline_name,
614
+ "timeline_fps": fps,
615
+ "item": {
616
+ "timeline_start_frame": tl_start,
617
+ "timeline_end_frame": tl_end,
618
+ "track_index": item.get("track_index") or 1,
619
+ "item_name": item.get("item_name"),
620
+ "current_shot_uuid": shot["shot_uuid"],
621
+ "current_description": shot.get("description"),
622
+ },
623
+ "alternates": alternates,
624
+ "summary": (
625
+ f"{len(alternates)} alternates for '{item.get('item_name') or 'item'}' "
626
+ f"on '{timeline_name}' (slot {tl_start}-{tl_end})"
627
+ ),
628
+ })
629
+ return {
630
+ "success": True,
631
+ "status": "plan_ready",
632
+ "plan_id": plan["plan_id"],
633
+ "kind": "swap",
634
+ "timeline_name": timeline_name,
635
+ "current_shot": {
636
+ "shot_uuid": shot["shot_uuid"],
637
+ "shot_index": shot["shot_index"],
638
+ "description": shot.get("description"),
639
+ },
640
+ "alternates": alternates,
641
+ "note": (
642
+ "Dry-run plan. Execute with edit_engine(action='execute_swap', "
643
+ "params={plan_id, alternate_index}) — the item is replaced on a "
644
+ "version-archived timeline (lift + positioned append, same slot)."
645
+ ),
646
+ }