davinci-resolve-mcp 2.67.0 → 2.68.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,372 @@
1
+ """Word-level transcript editing: cut plans with a reason per cut, and search.
2
+
3
+ Silence-based tightening (`silence_ripple`) can only remove the quiet. It cannot
4
+ remove an "um" that lands mid-phrase, a stammered restart, or a trailing "you
5
+ know" — those are *audible*, so no gate finds them. This module works on the
6
+ words themselves and is complementary, not a replacement.
7
+
8
+ What already existed and is reused rather than reimplemented:
9
+
10
+ - `transcript_words` — word-level timings in the strata DB.
11
+ - `detect_hesitations` — filler words (uh/um/er...), already an event track.
12
+ - `pause` events — already detected from the energy curve.
13
+
14
+ What is new here:
15
+
16
+ - **False-start detection.** Immediate self-repetition ("I— I think we", "we we
17
+ went"), which nothing upstream models.
18
+ - **Cut-plan assembly.** Turning those signals into `keep_ranges` with a
19
+ human-readable reason attached to every removal, so a plan can be argued with
20
+ rather than only accepted or rejected.
21
+ - **Cross-clip spoken search.** `query_transcript_words` matches a substring
22
+ within one clip; this searches phrases, word sets, or regex across a whole
23
+ shoot and returns hits with surrounding context.
24
+
25
+ ## Why every cut carries a reason
26
+
27
+ A keep-range list is not reviewable. "Removed 4.2s" tells an editor nothing;
28
+ "removed 'um' at 12.4s" and "removed a false start: 'I think— I think'" tells
29
+ them whether the pass understood the material. Reasons are the difference
30
+ between a plan a human can approve and one they have to re-derive.
31
+
32
+ ## Honest limits
33
+
34
+ False-start detection is a heuristic and is flagged as such. It cannot tell
35
+ rhetorical repetition ("no, no, no") from a stammer, so it only fires on tight
36
+ repeats — emphasis is spoken evenly, a stammer truncates. It will still
37
+ occasionally take a deliberate repetition; that is why nothing here executes,
38
+ it only proposes.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
45
+
46
+ #: Filler words. Shared with the strata analyzer's set so the two agree; kept as
47
+ #: a local import to avoid a heavy module-level dependency.
48
+ try:
49
+ from src.utils.strata_analyzers import HESITATION_WORDS as _HESITATION_WORDS
50
+ except Exception: # pragma: no cover - defensive; the set is small enough to inline
51
+ _HESITATION_WORDS = {"uh", "um", "er", "erm", "uhm", "hmm", "mm", "mhm", "ah", "eh"}
52
+
53
+ FILLER_WORDS = frozenset(_HESITATION_WORDS)
54
+
55
+ #: Trailing verbal tics — removable only at a phrase end, where they are padding.
56
+ #: Mid-phrase they usually carry meaning ("you know what I mean").
57
+ TRAILING_TICS = frozenset({"you know", "i mean", "sort of", "kind of", "right"})
58
+
59
+ #: Two identical tokens closer than this read as a stammer; wider apart they
60
+ #: read as deliberate emphasis, which must survive.
61
+ FALSE_START_MAX_GAP_S = 0.6
62
+ #: Longest repeated run treated as a restart. Beyond this it is a real re-take
63
+ #: and a human should choose.
64
+ FALSE_START_MAX_NGRAM = 4
65
+ #: Pauses longer than this are candidates for collapsing.
66
+ DEFAULT_MAX_PAUSE_S = 0.6
67
+ #: ...leaving this much either side, so speech does not butt against a cut.
68
+ DEFAULT_HANDLE_S = 0.15
69
+ #: Removals shorter than this are not worth an edit point.
70
+ DEFAULT_MIN_CUT_S = 0.15
71
+
72
+ _PUNCT_RE = re.compile(r"^[^\w']+|[^\w']+$")
73
+
74
+
75
+ def normalize_token(word: Any) -> str:
76
+ return _PUNCT_RE.sub("", str(word or "").strip().lower())
77
+
78
+
79
+ def _word_span(word: Dict[str, Any]) -> Optional[Tuple[float, float]]:
80
+ start = word.get("start_seconds", word.get("start"))
81
+ end = word.get("end_seconds", word.get("end"))
82
+ if not isinstance(start, (int, float)):
83
+ return None
84
+ if not isinstance(end, (int, float)) or end < start:
85
+ end = start
86
+ return float(start), float(end)
87
+
88
+
89
+ # ── detection ────────────────────────────────────────────────────────────────
90
+
91
+
92
+ def detect_false_starts(words: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
93
+ """Immediate self-repetition — the first run of the pair is the discard.
94
+
95
+ Scans longest-run-first (4 words down to 1) so "I think that / I think that"
96
+ is reported as one restart rather than three separate word repeats.
97
+
98
+ Heuristic, and deliberately tight: both runs must be adjacent in the word
99
+ stream and the gap between them under `FALSE_START_MAX_GAP_S`. Evenly-spaced
100
+ repetition is rhetorical and is left alone.
101
+ """
102
+ tokens = [normalize_token(w.get("word")) for w in words]
103
+ spans = [_word_span(w) for w in words]
104
+ found: List[Dict[str, Any]] = []
105
+ consumed: set[int] = set()
106
+
107
+ for size in range(FALSE_START_MAX_NGRAM, 0, -1):
108
+ for i in range(len(tokens) - 2 * size + 1):
109
+ idx_first = range(i, i + size)
110
+ idx_second = range(i + size, i + 2 * size)
111
+ if any(j in consumed for j in list(idx_first) + list(idx_second)):
112
+ continue
113
+ first = tokens[i : i + size]
114
+ second = tokens[i + size : i + 2 * size]
115
+ if not all(first) or first != second:
116
+ continue
117
+ span_a, span_b = spans[i], spans[i + size]
118
+ if span_a is None or span_b is None:
119
+ continue
120
+ gap = span_b[0] - spans[i + size - 1][1]
121
+ if gap > FALSE_START_MAX_GAP_S:
122
+ continue # evenly spaced — emphasis, not a stammer
123
+ end_of_first = spans[i + size - 1][1]
124
+ found.append({
125
+ "start_seconds": round(span_a[0], 3),
126
+ "end_seconds": round(end_of_first, 3),
127
+ "words": " ".join(words[j].get("word", "") for j in idx_first).strip(),
128
+ "repeated_as": " ".join(words[j].get("word", "") for j in idx_second).strip(),
129
+ "gap_seconds": round(gap, 3),
130
+ "confidence": "low" if size == 1 else "medium",
131
+ })
132
+ consumed.update(idx_first)
133
+ found.sort(key=lambda item: item["start_seconds"])
134
+ return found
135
+
136
+
137
+ def detect_fillers(words: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
138
+ """Standalone filler words. Mirrors the strata analyzer's word set."""
139
+ out: List[Dict[str, Any]] = []
140
+ for word in words:
141
+ span = _word_span(word)
142
+ if span is None:
143
+ continue
144
+ if normalize_token(word.get("word")) in FILLER_WORDS:
145
+ out.append({
146
+ "start_seconds": round(span[0], 3),
147
+ "end_seconds": round(span[1], 3),
148
+ "words": str(word.get("word") or "").strip(),
149
+ "confidence": "high",
150
+ })
151
+ return out
152
+
153
+
154
+ def detect_long_pauses(
155
+ words: Sequence[Dict[str, Any]], *, max_pause: float, handle: float, min_cut: float
156
+ ) -> List[Dict[str, Any]]:
157
+ """Gaps between words longer than `max_pause`, trimmed by a handle each side.
158
+
159
+ Word-boundary based, so unlike a silence gate this never cuts into a word
160
+ that merely dipped below a threshold.
161
+ """
162
+ out: List[Dict[str, Any]] = []
163
+ spans = [s for s in (_word_span(w) for w in words) if s is not None]
164
+ for (_, prev_end), (next_start, _) in zip(spans, spans[1:]):
165
+ gap = next_start - prev_end
166
+ if gap <= max_pause:
167
+ continue
168
+ start, end = prev_end + handle, next_start - handle
169
+ if end - start >= min_cut:
170
+ out.append({
171
+ "start_seconds": round(start, 3),
172
+ "end_seconds": round(end, 3),
173
+ "pause_seconds": round(gap, 3),
174
+ "confidence": "high",
175
+ })
176
+ return out
177
+
178
+
179
+ # ── plan assembly ────────────────────────────────────────────────────────────
180
+
181
+
182
+ def _merge(removals: List[Dict[str, Any]], *, bridge_gap: float = 0.0) -> List[Dict[str, Any]]:
183
+ """Merge overlapping removals, keeping every contributing reason.
184
+
185
+ `bridge_gap` also absorbs removals separated by less than one minimum cut.
186
+ Two cuts either side of a 25 ms sliver do not produce an edit a human wants —
187
+ they produce a 25 ms clip nobody can see and an extra pair of edit points.
188
+ If the fragment that would survive is too short to be a shot, it is not
189
+ worth keeping, so the removals either side become one.
190
+ """
191
+ if not removals:
192
+ return []
193
+ ordered = sorted(removals, key=lambda r: (r["start_seconds"], r["end_seconds"]))
194
+ merged = [dict(ordered[0], reasons=[ordered[0]["reason"]])]
195
+ for item in ordered[1:]:
196
+ last = merged[-1]
197
+ if item["start_seconds"] <= last["end_seconds"] + bridge_gap:
198
+ last["end_seconds"] = max(last["end_seconds"], item["end_seconds"])
199
+ if item["reason"] not in last["reasons"]:
200
+ last["reasons"].append(item["reason"])
201
+ else:
202
+ merged.append(dict(item, reasons=[item["reason"]]))
203
+ for item in merged:
204
+ item.pop("reason", None)
205
+ item["duration_seconds"] = round(item["end_seconds"] - item["start_seconds"], 3)
206
+ return merged
207
+
208
+
209
+ def plan_transcript_cuts(
210
+ words: Sequence[Dict[str, Any]],
211
+ *,
212
+ range_start: float = 0.0,
213
+ range_end: Optional[float] = None,
214
+ remove_fillers: bool = True,
215
+ remove_false_starts: bool = True,
216
+ collapse_pauses: bool = True,
217
+ max_pause: float = DEFAULT_MAX_PAUSE_S,
218
+ handle: float = DEFAULT_HANDLE_S,
219
+ min_cut: float = DEFAULT_MIN_CUT_S,
220
+ ) -> Dict[str, Any]:
221
+ """Propose word-level removals and the keep-ranges that survive them.
222
+
223
+ Returns `keep_ranges` in the same shape the variant assembler already takes,
224
+ plus `removals` where every entry states *why* — see the module docstring.
225
+ Nothing here executes; it proposes.
226
+ """
227
+ spans = [s for s in (_word_span(w) for w in words) if s is not None]
228
+ if not spans:
229
+ return {
230
+ "success": False,
231
+ "error": "No word timings in this transcript — run transcription first.",
232
+ "keep_ranges": [],
233
+ "removals": [],
234
+ }
235
+ end_of_speech = max(span[1] for span in spans)
236
+ limit = float(range_end) if range_end is not None else end_of_speech
237
+
238
+ removals: List[Dict[str, Any]] = []
239
+ counts = {"filler": 0, "false_start": 0, "pause": 0}
240
+
241
+ if remove_fillers:
242
+ for hit in detect_fillers(words):
243
+ removals.append({
244
+ "start_seconds": max(range_start, hit["start_seconds"] - handle / 2),
245
+ "end_seconds": min(limit, hit["end_seconds"] + handle / 2),
246
+ "reason": f"filler word {hit['words']!r}",
247
+ })
248
+ counts["filler"] += 1
249
+ if remove_false_starts:
250
+ for hit in detect_false_starts(words):
251
+ removals.append({
252
+ "start_seconds": max(range_start, hit["start_seconds"]),
253
+ "end_seconds": min(limit, hit["end_seconds"] + handle / 2),
254
+ "reason": (
255
+ f"false start: {hit['words']!r} restarted as {hit['repeated_as']!r} "
256
+ f"after {hit['gap_seconds']}s ({hit['confidence']} confidence)"
257
+ ),
258
+ })
259
+ counts["false_start"] += 1
260
+ if collapse_pauses:
261
+ for hit in detect_long_pauses(words, max_pause=max_pause, handle=handle, min_cut=min_cut):
262
+ removals.append({
263
+ "start_seconds": max(range_start, hit["start_seconds"]),
264
+ "end_seconds": min(limit, hit["end_seconds"]),
265
+ "reason": f"pause of {hit['pause_seconds']}s collapsed to {max_pause}s",
266
+ })
267
+ counts["pause"] += 1
268
+
269
+ removals = [r for r in removals if r["end_seconds"] - r["start_seconds"] >= min_cut]
270
+ merged = _merge(removals, bridge_gap=min_cut)
271
+
272
+ keep: List[Dict[str, float]] = []
273
+ cursor = float(range_start)
274
+ for item in merged:
275
+ if item["start_seconds"] > cursor:
276
+ keep.append({"start": round(cursor, 3), "end": round(item["start_seconds"], 3)})
277
+ cursor = max(cursor, item["end_seconds"])
278
+ if limit > cursor:
279
+ keep.append({"start": round(cursor, 3), "end": round(limit, 3)})
280
+ if not keep:
281
+ keep = [{"start": round(range_start, 3), "end": round(limit, 3)}]
282
+
283
+ removed = round(sum(item["duration_seconds"] for item in merged), 3)
284
+ return {
285
+ "success": True,
286
+ "keep_ranges": keep,
287
+ "removals": merged,
288
+ "counts": counts,
289
+ "source_duration_seconds": round(limit - range_start, 3),
290
+ "removed_seconds": removed,
291
+ "tightened_duration_seconds": round((limit - range_start) - removed, 3),
292
+ "note": (
293
+ "Proposal only. Every removal states its reason so the pass can be argued "
294
+ "with; false starts are a heuristic and may occasionally take a deliberate "
295
+ "repetition."
296
+ ),
297
+ }
298
+
299
+
300
+ # ── search ───────────────────────────────────────────────────────────────────
301
+
302
+
303
+ def _context(words: Sequence[Dict[str, Any]], lo: int, hi: int, context_s: float) -> Dict[str, Any]:
304
+ spans = [_word_span(w) for w in words]
305
+ hit_start = spans[lo][0] if spans[lo] else 0.0
306
+ hit_end = spans[hi][1] if spans[hi] else hit_start
307
+ ctx_lo, ctx_hi = lo, hi
308
+ while ctx_lo > 0 and spans[ctx_lo - 1] and hit_start - spans[ctx_lo - 1][0] <= context_s:
309
+ ctx_lo -= 1
310
+ while ctx_hi < len(words) - 1 and spans[ctx_hi + 1] and spans[ctx_hi + 1][1] - hit_end <= context_s:
311
+ ctx_hi += 1
312
+ return {
313
+ "start_seconds": round(hit_start, 3),
314
+ "end_seconds": round(hit_end, 3),
315
+ "text": " ".join(str(w.get("word") or "").strip() for w in words[lo : hi + 1]).strip(),
316
+ "context": " ".join(str(w.get("word") or "").strip() for w in words[ctx_lo : ctx_hi + 1]).strip(),
317
+ "context_start_seconds": round(spans[ctx_lo][0] if spans[ctx_lo] else hit_start, 3),
318
+ "context_end_seconds": round(spans[ctx_hi][1] if spans[ctx_hi] else hit_end, 3),
319
+ }
320
+
321
+
322
+ def search_words(
323
+ words: Sequence[Dict[str, Any]],
324
+ query: str,
325
+ *,
326
+ mode: str = "phrase",
327
+ context_seconds: float = 1.5,
328
+ ) -> List[Dict[str, Any]]:
329
+ """Find `query` in one clip's word stream. See `SEARCH_MODES` for modes."""
330
+ if mode not in SEARCH_MODES:
331
+ raise ValueError(f"unknown search mode {mode!r}; valid: {', '.join(SEARCH_MODES)}")
332
+ tokens = [t for t in (normalize_token(t) for t in query.split()) if t]
333
+ normalized = [normalize_token(w.get("word")) for w in words]
334
+ hits: List[Dict[str, Any]] = []
335
+
336
+ if mode == "phrase":
337
+ if not tokens:
338
+ return []
339
+ size = len(tokens)
340
+ for i in range(len(normalized) - size + 1):
341
+ if normalized[i : i + size] == tokens:
342
+ hits.append(_context(words, i, i + size - 1, context_seconds))
343
+ elif mode == "all_words":
344
+ # File-level AND gate: every query word must appear somewhere in this
345
+ # clip, else the clip contributes nothing.
346
+ present = set(normalized)
347
+ if not tokens or not all(t in present for t in tokens):
348
+ return []
349
+ wanted = set(tokens)
350
+ hits = [_context(words, i, i, context_seconds) for i, t in enumerate(normalized) if t in wanted]
351
+ else: # regex, matched over the running normalized text
352
+ pattern = re.compile(query, re.IGNORECASE)
353
+ offsets: List[Tuple[int, int, int]] = []
354
+ pieces: List[str] = []
355
+ cursor = 0
356
+ for idx, token in enumerate(normalized):
357
+ if not token:
358
+ continue
359
+ if pieces:
360
+ cursor += 1
361
+ offsets.append((cursor, cursor + len(token), idx))
362
+ pieces.append(token)
363
+ cursor += len(token)
364
+ text = " ".join(pieces)
365
+ for match in pattern.finditer(text):
366
+ covered = [wi for (lo, hi, wi) in offsets if lo < match.end() and hi > match.start()]
367
+ if covered:
368
+ hits.append(_context(words, covered[0], covered[-1], context_seconds))
369
+ return hits
370
+
371
+
372
+ SEARCH_MODES = ("phrase", "all_words", "regex")