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.
- package/CHANGELOG.md +270 -1
- package/README.md +16 -3
- package/README.zh-CN.md +261 -0
- package/docs/SKILL.md +6 -3
- package/docs/process/release-process.md +6 -0
- package/docs/reference/api-coverage.md +17 -10
- package/docs/reference/api-limitations.md +23 -5
- package/install.py +3 -3
- package/package.json +1 -1
- package/resolve-advanced/server/db-patch.mjs +43 -4
- package/resolve-advanced/vendor/drp-format/subtitle-style.js +11 -5
- package/scripts/doctor.py +4 -2
- package/scripts/resolve_bridge_launcher.py +2 -1
- package/src/analysis_dashboard.py +2 -2
- package/src/granular/common.py +1 -1
- package/src/granular/timeline_item.py +27 -1
- package/src/server.py +221 -21
- package/src/utils/api_truth.py +86 -7
- package/src/utils/clip_colors.py +94 -0
- package/src/utils/edit_engine.py +6 -0
- package/src/utils/resolve_bridge_client.py +6 -3
- package/src/utils/resolve_connection.py +47 -12
- package/src/utils/resolve_versions.py +338 -0
- package/src/utils/swallowed_retakes.py +139 -0
- package/src/utils/take_ranking.py +18 -1
|
@@ -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** (
|
|
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
|
|
47
|
-
asked for it, silently using a different
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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. "
|