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.
- package/CHANGELOG.md +117 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/docs/kernels/render-deliver-kernel.md +14 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -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")
|