davinci-resolve-mcp 2.82.0 → 2.86.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.
@@ -37,20 +37,51 @@ def _network_timeout():
37
37
  return timeout
38
38
 
39
39
 
40
+ def _try_bridge_fallback():
41
+ """Last-resort bridge attempt after a direct transport has already failed.
42
+
43
+ Returns a proxy, or None when there is no bridge to reach. Unlike the
44
+ explicitly-requested path this swallows `BridgeUnavailable`: nobody asked for
45
+ the bridge, so "no bridge running" is simply "no Resolve" — the same answer
46
+ the caller got before this fallback existed.
47
+ """
48
+ if bridge_client is None:
49
+ return None
50
+ try:
51
+ proxy = bridge_client.connect(require_enabled=False)
52
+ except bridge_client.BridgeUnavailable as exc:
53
+ logger.debug("no in-app bridge to fall back to: %s", exc)
54
+ return None
55
+ logger.info(
56
+ "external scripting unavailable; automatically using the in-app bridge "
57
+ "(set %s=1 to require it and surface bridge faults directly)",
58
+ bridge_client.ENV_ENABLE,
59
+ )
60
+ return proxy
61
+
62
+
40
63
  def connect_resolve(dvr_script):
41
64
  """Create a Resolve handle. Three modes, tried in order of directness.
42
65
 
43
- 1. **Bridge** (opt-in, `DAVINCI_RESOLVE_BRIDGE=1`) — a proxy over the
66
+ 1. **Bridge** (forced, `DAVINCI_RESOLVE_BRIDGE=1`) — a proxy over the
44
67
  authenticated loopback bridge running inside Resolve. This is the only
45
68
  mode that works on the **free edition**, whose external scripting API
46
- refuses foreign processes. Tried first when enabled, because if a user has
47
- asked for it, silently using a different transport would hide a broken
48
- bridge.
69
+ refuses foreign processes. Tried first *and exclusively* when the env var
70
+ is set, because if a user has asked for it, silently using a different
71
+ transport would hide a broken bridge.
49
72
  2. **Network** (`RESOLVE_SCRIPT_HOST`) — Studio's remote scripting.
50
73
  3. **Local** — Studio's local scripting. The default.
51
74
 
52
- `dvr_script` may be None in bridge mode: the bridge does not need Blackmagic's
53
- module at all, which is precisely why it reaches editions the module cannot.
75
+ If a direct transport yields nothing, the bridge is tried once more as a
76
+ **fallback**, without the env var. That cannot mask a broken bridge the way
77
+ an unconditional bridge-first order would: it only runs on a path that has
78
+ already failed, so the env var stays meaningful as "require the bridge and
79
+ tell me when it breaks" while the free edition works with no configuration
80
+ beyond starting the in-Resolve script.
81
+
82
+ `dvr_script` may be None: the bridge does not need Blackmagic's module at
83
+ all, which is precisely why it reaches editions the module cannot — so a
84
+ missing module is a reason to try the bridge, not to give up.
54
85
  """
55
86
  if bridge_client is not None and bridge_client.bridge_enabled():
56
87
  try:
@@ -60,12 +91,16 @@ def connect_resolve(dvr_script):
60
91
  # falling through to a transport the user did not ask for.
61
92
  logger.error("in-app bridge requested but unavailable: %s", exc)
62
93
  raise
63
- if dvr_script is None:
64
- return None
65
- host = os.environ.get("RESOLVE_SCRIPT_HOST")
66
- if host:
67
- return dvr_script.scriptapp("Resolve", host, _network_timeout())
68
- return dvr_script.scriptapp("Resolve")
94
+ resolve = None
95
+ if dvr_script is not None:
96
+ host = os.environ.get("RESOLVE_SCRIPT_HOST")
97
+ if host:
98
+ resolve = dvr_script.scriptapp("Resolve", host, _network_timeout())
99
+ else:
100
+ resolve = dvr_script.scriptapp("Resolve")
101
+ if resolve is not None:
102
+ return resolve
103
+ return _try_bridge_fallback()
69
104
 
70
105
 
71
106
  def initialize_resolve():
@@ -0,0 +1,338 @@
1
+ """Resolve build comparison, and the API surfaces that are gated on a build.
2
+
3
+ The scripting API changes per **patch** release, not per major one. "Resolve 21"
4
+ is not a fine enough label to route on: `GetFairlightPresets` exists on 20.2.2
5
+ and not on 19.1.3, and issue #131 reports three surfaces that appear only in
6
+ 21.0.4. An agent given the same guidance regardless of the build it is connected
7
+ to will confidently describe a method that is not there — which is exactly the
8
+ report in issue #132.
9
+
10
+ Two separate questions live here, and conflating them is the trap:
11
+
12
+ 1. **Does this build have the method?** Answered from `VERSION_GATES` below,
13
+ which records only gates we can point at evidence for.
14
+ 2. **Was a recorded quirk measured on a build like mine?** Answered by comparing
15
+ an `api_truth` entry's stamp against the live build. A fact measured on
16
+ 19.1.3.7 is a *prior* on 21.0.4, not a finding.
17
+
18
+ Neither question is answered by guessing. A symbol absent from `VERSION_GATES`
19
+ returns `unknown`, never `available` — the registry records what we know, and
20
+ most of the API has never been version-bisected. `unknown` means "probe it",
21
+ which is the honest instruction; a false `available` is how an agent ends up
22
+ insisting a method exists.
23
+ """
24
+
25
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
26
+
27
+ # ── Version parsing ─────────────────────────────────────────────────────────
28
+ # Resolve reports versions three ways depending on where you ask:
29
+ # Resolve.GetVersion() -> [19, 1, 3, 7, ""] (list, trailing build str)
30
+ # Resolve.GetVersionString() -> "19.1.3.7"
31
+ # and prose in this repo says things like "Studio 19.1.3.7 (macOS)".
32
+
33
+
34
+ def parse_version(value: Any) -> Optional[Tuple[int, ...]]:
35
+ """Best-effort parse to a comparable tuple. None when nothing numeric is found.
36
+
37
+ Accepts the list form, the dotted string, or a sentence containing one. Only
38
+ leading numeric components are kept, so "21.0.4.5" -> (21, 0, 4, 5) and the
39
+ trailing build-name string Resolve appends is dropped rather than coerced.
40
+ """
41
+ # bool is a subclass of int, so True would otherwise parse as version (1,) —
42
+ # a truthy flag leaking in must not become a plausible-looking build number.
43
+ if value is None or isinstance(value, bool):
44
+ return None
45
+ if isinstance(value, (list, tuple)):
46
+ parts: List[int] = []
47
+ for item in value:
48
+ if isinstance(item, bool):
49
+ break
50
+ if isinstance(item, int):
51
+ parts.append(item)
52
+ elif isinstance(item, str) and item.strip().isdigit():
53
+ parts.append(int(item.strip()))
54
+ else:
55
+ break
56
+ return tuple(parts) or None
57
+ if isinstance(value, (int, float)):
58
+ return (int(value),)
59
+ if not isinstance(value, str):
60
+ return None
61
+
62
+ best: Optional[Tuple[int, ...]] = None
63
+ token: List[str] = []
64
+ for char in list(value) + [" "]:
65
+ if char.isdigit() or char == ".":
66
+ token.append(char)
67
+ continue
68
+ if token:
69
+ parts = [p for p in "".join(token).split(".") if p.isdigit()]
70
+ if parts:
71
+ candidate = tuple(int(p) for p in parts)
72
+ # Prefer the most specific run, so "Studio 19.1.3.7" beats a bare
73
+ # "19" appearing earlier in the same sentence.
74
+ if best is None or len(candidate) > len(best):
75
+ best = candidate
76
+ token = []
77
+ return best
78
+
79
+
80
+ def compare_versions(a: Any, b: Any) -> Optional[int]:
81
+ """-1/0/1 for a<b, a==b, a>b. None when either side will not parse.
82
+
83
+ Shorter tuples compare as though zero-padded: 21.0 == 21.0.0.
84
+ """
85
+ va, vb = parse_version(a), parse_version(b)
86
+ if va is None or vb is None:
87
+ return None
88
+ width = max(len(va), len(vb))
89
+ pa = va + (0,) * (width - len(va))
90
+ pb = vb + (0,) * (width - len(vb))
91
+ return (pa > pb) - (pa < pb)
92
+
93
+
94
+ def at_least(live: Any, required: Any) -> Optional[bool]:
95
+ """True when `live` >= `required`. None when either will not parse."""
96
+ result = compare_versions(live, required)
97
+ return None if result is None else result >= 0
98
+
99
+
100
+ # ── Version gates ───────────────────────────────────────────────────────────
101
+ # Only surfaces with evidence behind them. `source` separates what this repo
102
+ # measured from what a reporter measured, because the two carry different
103
+ # weight and an agent relaying the fact should be able to say which it is.
104
+ #
105
+ # measured — probed live by this project
106
+ # reported — a user's live probe, credited, not independently reproduced
107
+ # vendor — stated by Blackmagic's shipped Developer/Scripting/README.txt
108
+
109
+ VERSION_GATES: List[Dict[str, Any]] = [
110
+ {
111
+ "symbol": "Resolve.GetFairlightPresets",
112
+ "introduced_in": "20.2.2",
113
+ "source": "measured",
114
+ "note": "Absent on 19.1.3 (confirmed live). Without it the per-parameter "
115
+ "Fairlight gap is the whole story, because the whole-mix preset "
116
+ "workaround is unavailable.",
117
+ "issue": 128,
118
+ },
119
+ {
120
+ "symbol": "Timeline.ApplyFairlightPresetToCurrentTimeline",
121
+ "introduced_in": "20.2.2",
122
+ "source": "measured",
123
+ "note": "Same gate as GetFairlightPresets; the two are only useful together.",
124
+ "issue": 128,
125
+ },
126
+ {
127
+ "symbol": "Timeline.GetSelectedClips",
128
+ "introduced_in": "21.0.4",
129
+ "source": "reported",
130
+ "note": "Reported against 21.0.4.5 and listed in the shipped scripting "
131
+ "README. This repo's selection helper duck-probes three names and "
132
+ "reaches it by luck on builds that have it, so an absence here "
133
+ "degrades rather than errors.",
134
+ "issue": 131,
135
+ },
136
+ {
137
+ "symbol": "MediaPoolItem.GetTimeline",
138
+ "introduced_in": "21.0.4",
139
+ "source": "reported",
140
+ "note": "Reported against 21.0.4.5. Not reachable through this server yet.",
141
+ "issue": 131,
142
+ },
143
+ {
144
+ "symbol": "Project.SetRenderSettings UseFullExtents",
145
+ "introduced_in": "21.0.4",
146
+ "source": "vendor",
147
+ "note": "New SetRenderSettings key per the shipped scripting README. "
148
+ "SetRenderSettings ignores unknown keys silently, so on an older "
149
+ "build this is dropped with no signal rather than refused.",
150
+ "issue": 131,
151
+ },
152
+ {
153
+ "symbol": "Project.SetRenderSettings AddFrameHandles",
154
+ "introduced_in": "21.0.4",
155
+ "source": "vendor",
156
+ "note": "New SetRenderSettings key; ignored when full extents is enabled, "
157
+ "so it can do nothing for two different reasons.",
158
+ "issue": 131,
159
+ },
160
+ {
161
+ "symbol": "Project.SetRenderSettings DataBurnIn",
162
+ "introduced_in": "21.0.4",
163
+ "source": "vendor",
164
+ "note": "New SetRenderSettings key per the shipped scripting README.",
165
+ "issue": 131,
166
+ },
167
+ ]
168
+
169
+ _GATES_BY_SYMBOL = {gate["symbol"].lower(): gate for gate in VERSION_GATES}
170
+
171
+ AVAILABLE = "available"
172
+ UNAVAILABLE = "unavailable"
173
+ UNKNOWN = "unknown"
174
+
175
+
176
+ def gate_for(symbol: str) -> Optional[Dict[str, Any]]:
177
+ """The recorded gate for `symbol`, matched loosely. None if we have none."""
178
+ if not symbol:
179
+ return None
180
+ needle = symbol.strip().lower()
181
+ if needle in _GATES_BY_SYMBOL:
182
+ return _GATES_BY_SYMBOL[needle]
183
+ for key, gate in _GATES_BY_SYMBOL.items():
184
+ if needle in key or key in needle:
185
+ return gate
186
+ return None
187
+
188
+
189
+ def availability(symbol: str, live_version: Any) -> Dict[str, Any]:
190
+ """Is `symbol` present on `live_version`? Says `unknown` unless it knows.
191
+
192
+ The default matters more than the positive cases. Most of the API has never
193
+ been version-bisected, so anything outside VERSION_GATES is unknown — and an
194
+ agent should probe rather than assert.
195
+ """
196
+ gate = gate_for(symbol)
197
+ if gate is None:
198
+ return {
199
+ "status": UNKNOWN,
200
+ "symbol": symbol,
201
+ "reason": "No version gate is recorded for this symbol.",
202
+ "remediation": (
203
+ "Probe it on the live build with `name in dir(obj)` rather than "
204
+ "assuming either way. Do NOT use hasattr: on a Resolve API object "
205
+ "hasattr returns True for EVERY name, real or invented (measured on "
206
+ "Studio 19.1.3.7), so it can only ever say yes. "
207
+ "`callable(getattr(obj, name, None))` also works on the direct "
208
+ "connection, but dir() membership is the form unaffected by the "
209
+ "attribute-fabrication question — see the api_truth entry "
210
+ "'hasattr() / getattr() on Resolve API objects'. Most of the "
211
+ "scripting API has never been version-bisected here."
212
+ ),
213
+ }
214
+ ok = at_least(live_version, gate["introduced_in"])
215
+ if ok is None:
216
+ return {
217
+ "status": UNKNOWN,
218
+ "symbol": gate["symbol"],
219
+ "introduced_in": gate["introduced_in"],
220
+ "source": gate["source"],
221
+ "reason": f"Could not parse the live version ({live_version!r}).",
222
+ "remediation": "Read resolve_control get_version and pass version_string.",
223
+ }
224
+ return {
225
+ "status": AVAILABLE if ok else UNAVAILABLE,
226
+ "symbol": gate["symbol"],
227
+ "introduced_in": gate["introduced_in"],
228
+ "live_version": str(live_version),
229
+ "source": gate["source"],
230
+ "note": gate.get("note"),
231
+ "issue": gate.get("issue"),
232
+ "reason": (
233
+ f"{gate['symbol']} is recorded as introduced in {gate['introduced_in']} "
234
+ f"({gate['source']}); this build is {live_version}."
235
+ ),
236
+ "remediation": None if ok else (
237
+ f"Not present on this build. Do not offer it — say the build is too old "
238
+ f"and name {gate['introduced_in']} as the floor."
239
+ ),
240
+ }
241
+
242
+
243
+ def gates_unavailable_on(live_version: Any) -> List[Dict[str, Any]]:
244
+ """Every recorded gate this build does NOT clear, for a session preflight."""
245
+ out = []
246
+ for gate in VERSION_GATES:
247
+ if at_least(live_version, gate["introduced_in"]) is False:
248
+ out.append({
249
+ "symbol": gate["symbol"],
250
+ "introduced_in": gate["introduced_in"],
251
+ "source": gate["source"],
252
+ "issue": gate.get("issue"),
253
+ "note": gate.get("note"),
254
+ })
255
+ return out
256
+
257
+
258
+ # ── Applying a recorded fact to the live build ──────────────────────────────
259
+
260
+ def measurement_relevance(verified_on: Any, live_version: Any) -> Dict[str, Any]:
261
+ """How much weight does a fact measured on `verified_on` carry here?
262
+
263
+ Deliberately three-valued. A fact measured on an older build is not wrong,
264
+ it is unconfirmed — and saying so is different from both "applies" and
265
+ "does not apply".
266
+ """
267
+ result = compare_versions(verified_on, live_version)
268
+ if result is None:
269
+ return {
270
+ "relevance": UNKNOWN,
271
+ "note": "Could not compare the measurement stamp with the live build.",
272
+ }
273
+ if result == 0:
274
+ return {
275
+ "relevance": "same_build",
276
+ "note": "Measured on this exact build.",
277
+ }
278
+ if result < 0:
279
+ return {
280
+ "relevance": "older_measurement",
281
+ "note": (
282
+ f"Measured on {verified_on}, older than this build ({live_version}). "
283
+ "Treat it as a prior, not a finding — Blackmagic changes the "
284
+ "scripting API per patch release. Re-confirm before relying on it."
285
+ ),
286
+ }
287
+ return {
288
+ "relevance": "newer_measurement",
289
+ "note": (
290
+ f"Measured on {verified_on}, NEWER than this build ({live_version}). "
291
+ "The behaviour described may not exist here yet, and a fix recorded "
292
+ "there has certainly not landed here."
293
+ ),
294
+ }
295
+
296
+
297
+ def annotate_facts(
298
+ facts: Sequence[Dict[str, Any]],
299
+ live_version: Any,
300
+ *,
301
+ ledger_verified_on: Optional[str] = None,
302
+ ) -> List[Dict[str, Any]]:
303
+ """Copy `facts` with a `version_context` block against the live build.
304
+
305
+ `ledger_verified_on` is the module-wide stamp — when the ledger as a whole
306
+ was last reviewed. It is deliberately NOT used as a per-fact measurement:
307
+ most entries record the build they were actually measured on in their prose
308
+ (19.1.3.7, 21.0.0, 21.0.2.4 …), and attributing the ledger-wide stamp to
309
+ each of them would invent a measurement that never happened. An entry with
310
+ no structured `verified_on` therefore reports relevance `unknown` and says
311
+ where to look — which is true, and points at the real gap.
312
+
313
+ Non-destructive: entries are copied, so the ledger is never mutated.
314
+ """
315
+ if not live_version:
316
+ return [dict(fact) for fact in facts]
317
+ out = []
318
+ for fact in facts:
319
+ annotated = dict(fact)
320
+ context: Dict[str, Any] = {"live_version": str(live_version)}
321
+ stamp = fact.get("verified_on")
322
+ if stamp:
323
+ context.update(measurement_relevance(stamp, live_version))
324
+ context["measured_on"] = stamp
325
+ else:
326
+ context["relevance"] = UNKNOWN
327
+ context["note"] = (
328
+ "This entry carries no structured measurement stamp; the build it "
329
+ "was verified on is stated in its `reality` text. Read it there "
330
+ "before relying on the fact."
331
+ )
332
+ if ledger_verified_on:
333
+ context["ledger_reviewed_on"] = ledger_verified_on
334
+ if gate_for(fact.get("symbol", "")) or fact.get("min_version"):
335
+ context["availability"] = availability(fact.get("symbol", ""), live_version)
336
+ annotated["version_context"] = context
337
+ out.append(annotated)
338
+ return out
@@ -0,0 +1,139 @@
1
+ """Flag transcript words that may have swallowed an immediate retake.
2
+
3
+ When a speaker re-reads a sentence immediately — the normal way people
4
+ self-correct while recording — whisper emits the text ONCE, aligns it to the
5
+ FIRST take, and absorbs "pause + entire second take" into the duration of a
6
+ single word. The transcript then says the sentence was spoken once, cleanly.
7
+ Every transcript-reading feature downstream inherits that lie: retake
8
+ detection never fires, fluency scoring undercounts restarts, and a cut placed
9
+ on a word boundary near the swallow point lands inside speech.
10
+
11
+ Energy detection cannot catch it either. In the original measurement the
12
+ swallowed span was breathing and keyboard noise peaking at -12.1 dB, LOUDER
13
+ than the adjacent real speech at -16.7 dB, so `silencedetect` recalled 3 of 17
14
+ ground-truth instances at any threshold. See issue #125.
15
+
16
+ What this module does is deliberately small: it flags words whose duration is
17
+ implausible for a single spoken word, and stops. It does not propose a cut
18
+ point, because the data cannot support one — where inside the stretched word
19
+ the second take begins is exactly what the timestamps have destroyed.
20
+
21
+ **Why an absolute bar, after two better-sounding ideas failed.**
22
+ The reporter measured all three on English material (2026-08-06, synthetic but
23
+ acoustically matched, ground truth from a sample-accurate splice log):
24
+
25
+ - A characters-per-second gate (`>= 2.5s and < 4.5 chars/s`), which worked on
26
+ the original Chinese material, fires on **0 of 50** English segments — the
27
+ swallowed segment itself runs 8.81 chars/s against an English median of 14.5.
28
+ Characters per second is not comparable across writing systems; it is retired
29
+ here rather than made configurable.
30
+ - Scoring each word against the distribution of its OWN durations elsewhere in
31
+ the take — self-calibrating, and the obvious language-agnostic fix — missed
32
+ the swallow entirely and produced 8 false positives, all function words. The
33
+ reason is structural and worth keeping: the words that absorb retakes are
34
+ content words, and content words are naturally rare within one take, so they
35
+ never accumulate a distribution to be scored against.
36
+ - The plain absolute bar found exactly one stretched word in 191.5s, and it was
37
+ the swallow point.
38
+
39
+ So: one threshold, no calibration, no language assumptions.
40
+
41
+ **Confidence.** The English confirmation rests on a single genuine swallow
42
+ (n=1) in synthetic material — 13 of 15 planted retakes transcribed too cleanly
43
+ to swallow, which is itself a limit of TTS-generated speech. The Chinese
44
+ measurement it generalises from had 17 real instances. Treat the bar as a
45
+ usable default, not a tuned constant.
46
+ """
47
+
48
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
49
+
50
+ # Seconds. A single spoken word above this is implausible in any language the
51
+ # reporter measured; the one true positive ran well past it.
52
+ DEFAULT_MIN_WORD_SECONDS = 1.2
53
+
54
+ CAVEAT = (
55
+ "A flag is not a cut point. Where inside a stretched word the second take "
56
+ "begins is precisely what the swallow destroyed, so these need a human ear "
57
+ "(or a re-transcription pass over the isolated window) before anything is "
58
+ "removed. Absence of flags is not evidence of no retakes: a swallow that "
59
+ "smeared across several words rather than one will not clear the bar."
60
+ )
61
+
62
+
63
+ def _seconds(row: Mapping[str, Any], *keys: str) -> Optional[float]:
64
+ for key in keys:
65
+ value = row.get(key)
66
+ if isinstance(value, (int, float)):
67
+ return float(value)
68
+ return None
69
+
70
+
71
+ def _word_text(row: Mapping[str, Any]) -> str:
72
+ for key in ("word", "text", "token"):
73
+ value = row.get(key)
74
+ if isinstance(value, str) and value.strip():
75
+ return value.strip()
76
+ return ""
77
+
78
+
79
+ def find_swallowed_retake_candidates(
80
+ words: Sequence[Mapping[str, Any]],
81
+ *,
82
+ min_word_seconds: float = DEFAULT_MIN_WORD_SECONDS,
83
+ ) -> List[Dict[str, Any]]:
84
+ """Words whose duration exceeds the bar, in time order.
85
+
86
+ Accepts either transcript_words DB rows (`start_seconds`/`end_seconds`) or
87
+ raw whisper word dicts (`start`/`end`); a row missing either endpoint is
88
+ skipped rather than guessed at.
89
+ """
90
+ if not words or min_word_seconds <= 0:
91
+ return []
92
+ out: List[Dict[str, Any]] = []
93
+ for index, row in enumerate(words):
94
+ if not isinstance(row, Mapping):
95
+ continue
96
+ start = _seconds(row, "start_seconds", "start")
97
+ end = _seconds(row, "end_seconds", "end")
98
+ if start is None or end is None:
99
+ continue
100
+ duration = end - start
101
+ if duration < min_word_seconds:
102
+ continue
103
+ out.append({
104
+ "word_index": index,
105
+ "word": _word_text(row),
106
+ "start_seconds": round(start, 3),
107
+ "end_seconds": round(end, 3),
108
+ "duration_seconds": round(duration, 3),
109
+ "reason": (
110
+ f"word spans {duration:.2f}s, over the {min_word_seconds:.2f}s bar — "
111
+ "a retake may be absorbed into this word's duration"
112
+ ),
113
+ })
114
+ out.sort(key=lambda row: row["start_seconds"])
115
+ return out
116
+
117
+
118
+ def swallowed_retake_report(
119
+ words: Sequence[Mapping[str, Any]],
120
+ *,
121
+ min_word_seconds: float = DEFAULT_MIN_WORD_SECONDS,
122
+ ) -> Dict[str, Any]:
123
+ """`find_swallowed_retake_candidates` plus the caveat, ready to attach."""
124
+ candidates = find_swallowed_retake_candidates(words, min_word_seconds=min_word_seconds)
125
+ return {
126
+ "count": len(candidates),
127
+ "min_word_seconds": min_word_seconds,
128
+ "candidates": candidates,
129
+ "caveat": CAVEAT,
130
+ "note": (
131
+ f"{len(candidates)} word(s) long enough to have swallowed an immediate "
132
+ "retake. Whisper reports a re-read sentence once and absorbs the pause "
133
+ "plus the second take into one word, so these are invisible to both the "
134
+ "transcript and any silence threshold (issue #125)."
135
+ if candidates
136
+ else "No words cleared the stretched-word bar. That is weak evidence, "
137
+ "not proof: a swallow spread over several words will not show up here."
138
+ ),
139
+ }
@@ -160,7 +160,10 @@ RANKING_CAVEAT = (
160
160
  "most of what makes a take the right one. The take that plays best is "
161
161
  "regularly the least fluent one: the hesitation is often the acting. Use "
162
162
  "this to find the clean safety take or to skip the warm-ups, not to choose "
163
- "the read."
163
+ "the read. It also UNDERCOUNTS restarts: an immediate re-read is reported "
164
+ "by whisper as a single clean utterance, so a take that stumbled and "
165
+ "recovered can outscore one that did not (issue #125). Check "
166
+ "possible_swallowed_retakes before trusting a close ranking."
164
167
  )
165
168
 
166
169
 
@@ -185,6 +188,19 @@ def rank_takes(
185
188
  take["rank"] = position
186
189
 
187
190
  unreliable = [s["label"] for s in scored if not s["reliable"]]
191
+
192
+ # Restarts WITHIN a take are precisely what whisper's timestamps hide: an
193
+ # immediate re-read is emitted once, so the restart never reaches the filler
194
+ # or false-start counts and the take scores as cleaner than it was. Flagging
195
+ # the stretched words at least tells the caller which takes to distrust
196
+ # rather than letting the ranking imply it saw everything (issue #125).
197
+ from src.utils import swallowed_retakes as _swallowed
198
+ swallowed: Dict[str, Any] = {}
199
+ for take, source in zip(scored, takes):
200
+ report = _swallowed.swallowed_retake_report(source.get("words") or [])
201
+ if report["count"]:
202
+ swallowed[take["label"]] = report
203
+
188
204
  return {
189
205
  "success": True,
190
206
  "kind": "take_ranking",
@@ -194,6 +210,7 @@ def rank_takes(
194
210
  "most_fluent": scored[0]["label"] if scored else None,
195
211
  "script_supplied": bool(script and script.strip()),
196
212
  "unreliable_takes": unreliable,
213
+ "possible_swallowed_retakes": swallowed,
197
214
  "caveat": RANKING_CAVEAT,
198
215
  "note": (
199
216
  f"Ranked {len(scored)} takes by fluency. "