davinci-resolve-mcp 2.43.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.
- package/CHANGELOG.md +67 -0
- package/README.md +3 -3
- package/docs/SKILL.md +56 -1
- package/docs/guides/control-panel.md +4 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +49 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +549 -1
- package/src/utils/analysis_store.py +20 -2
- package/src/utils/destructive_hook.py +5 -0
- package/src/utils/edit_engine.py +646 -0
- package/src/utils/entities.py +579 -0
- package/src/utils/timeline_brain_db.py +48 -1
|
@@ -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
|
+
}
|