davinci-resolve-mcp 2.82.1 → 2.86.2
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 +267 -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 +4 -4
- 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/server.py +158 -19
- package/src/utils/api_truth.py +29 -7
- 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
package/src/utils/api_truth.py
CHANGED
|
@@ -1008,7 +1008,8 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1008
1008
|
"a normalised position vector (param 17). Exposed as "
|
|
1009
1009
|
"project_db list_subtitle_styles / set_subtitle_style "
|
|
1010
1010
|
"(font family/size/weight/italic + position). Confirmed "
|
|
1011
|
-
"live on
|
|
1011
|
+
"live on BOTH editions 2026-08-06 — Studio 19.1.3 and "
|
|
1012
|
+
"free 21.0.3, identical behaviour: Resolve opens a patched track "
|
|
1012
1013
|
"and re-serialises it back to its own zstd form with the "
|
|
1013
1014
|
"patched values intact, so it genuinely parses the write. "
|
|
1014
1015
|
"Caveats: whole-TRACK style not per-caption, project must "
|
|
@@ -1070,13 +1071,29 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1070
1071
|
"NOT refute the 21.0.0 record: if the bridge resolves any name "
|
|
1071
1072
|
"known to the RemoteObject method table rather than literally any "
|
|
1072
1073
|
"string, an invented name is correctly rejected on both builds and "
|
|
1073
|
-
"the probe never exercised the failing case.
|
|
1074
|
-
"
|
|
1075
|
-
"
|
|
1074
|
+
"the probe never exercised the failing case. "
|
|
1075
|
+
"PARTLY SETTLED 2026-08-06 on Studio 19.1.3.7, DIRECT connection "
|
|
1076
|
+
"(not the bridge): the five real borrowed names were re-run as the "
|
|
1077
|
+
"entry asked, across Resolve, ProjectManager, Project, MediaPool, "
|
|
1078
|
+
"Timeline, Folder and TimelineItem — 42 checks. Every one returned "
|
|
1079
|
+
"getattr-callable False and dir()-absent, agreeing in all 42 cases, "
|
|
1080
|
+
"exactly like the invented control. So NO fabrication on the direct "
|
|
1081
|
+
"path on this build, and the borrowed-name case the 21.0.2.4 probe "
|
|
1082
|
+
"missed is now covered there. What the same run DID establish is "
|
|
1083
|
+
"narrower and worse than expected: bare hasattr() returns True for "
|
|
1084
|
+
"EVERY name on every object — real, borrowed or invented — while "
|
|
1085
|
+
"getattr returns None. hasattr alone is therefore not a weak probe, "
|
|
1086
|
+
"it is a constant True and carries no information at all. "
|
|
1087
|
+
"STILL OPEN: the 21.0.0 record was against the BRIDGE, which this "
|
|
1088
|
+
"run did not exercise, so bridge-side fabrication remains untested "
|
|
1089
|
+
"and the recommendation below stands unchanged.",
|
|
1076
1090
|
"recommended": "Use dir(obj) membership for capability probes. It is correct "
|
|
1077
1091
|
"on every build measured, and it is the only form not affected "
|
|
1078
|
-
"by whichever way this resolves.
|
|
1079
|
-
"
|
|
1092
|
+
"by whichever way this resolves. NEVER use bare hasattr(): it "
|
|
1093
|
+
"is a constant True on Resolve objects and cannot distinguish "
|
|
1094
|
+
"anything. server._has_method uses callable(getattr(...)), "
|
|
1095
|
+
"which agreed with dir() in all 42 direct-path checks on "
|
|
1096
|
+
"19.1.3.7 but may still over-report on a bridge build where "
|
|
1080
1097
|
"fabrication is live — that is the case _requires_method gates "
|
|
1081
1098
|
"guard, so it matters most exactly where it is least tested. "
|
|
1082
1099
|
"Calling a fabricated method typically returns None/False with "
|
|
@@ -1444,7 +1461,12 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1444
1461
|
"are all marker-only and all refused. Five names overlap both "
|
|
1445
1462
|
"palettes (Blue, Green, Yellow, Pink, Purple), which is why the "
|
|
1446
1463
|
"decoy survives: reasoning from the marker constants scores 5 of "
|
|
1447
|
-
"16 and looks like the right vocabulary with a few gaps."
|
|
1464
|
+
"16 and looks like the right vocabulary with a few gaps. The "
|
|
1465
|
+
"reporter's field experience sharpens this — the trap is not that "
|
|
1466
|
+
"a decoy vocabulary exists, it is that the decoy HALF-WORKS. "
|
|
1467
|
+
"Intermittent successes read as an unreliable API rather than as "
|
|
1468
|
+
"a wrong vocabulary, so the wrong conclusion is the natural one; "
|
|
1469
|
+
"a clean 0-for-8 would have exposed the mechanism immediately.",
|
|
1448
1470
|
"recommended": "Pass only the 16 clip-colour names; on False, treat it as an "
|
|
1449
1471
|
"invalid name rather than an item/lock/page problem. The set "
|
|
1450
1472
|
"is pinned in utils/clip_colors.py and named in the refusal "
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -1742,6 +1742,12 @@ def plan_transcript_tighten(
|
|
|
1742
1742
|
plan.get("keep_ranges") or [],
|
|
1743
1743
|
source_duration_seconds=float(duration) if isinstance(duration, (int, float)) and duration else None,
|
|
1744
1744
|
)
|
|
1745
|
+
# The class this planner is structurally blind to. Its false-start heuristic
|
|
1746
|
+
# only sees restarts the transcript actually reports; an immediate re-read is
|
|
1747
|
+
# emitted ONCE with the pause and second take absorbed into one word, so the
|
|
1748
|
+
# plan cannot propose removing what it was never told happened (issue #125).
|
|
1749
|
+
from src.utils import swallowed_retakes as _swallowed
|
|
1750
|
+
plan["possible_swallowed_retakes"] = _swallowed.swallowed_retake_report(words)
|
|
1745
1751
|
return plan
|
|
1746
1752
|
|
|
1747
1753
|
|
|
@@ -55,8 +55,10 @@ logger = logging.getLogger("resolve-mcp.bridge-client")
|
|
|
55
55
|
DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
|
|
56
56
|
#: Env override, so a caller can point at a non-default bridge without editing code.
|
|
57
57
|
ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
|
|
58
|
-
#:
|
|
59
|
-
#:
|
|
58
|
+
#: Forces the bridge: it becomes the only transport tried, so its faults surface
|
|
59
|
+
#: directly. Absent does NOT mean "never try the bridge" — `connect_resolve` falls
|
|
60
|
+
#: back to it when a direct transport yields nothing, which is what lets the free
|
|
61
|
+
#: edition work with no configuration beyond starting the in-Resolve script.
|
|
60
62
|
ENV_ENABLE = "DAVINCI_RESOLVE_BRIDGE"
|
|
61
63
|
|
|
62
64
|
#: Operations this client cannot work without. The in-Resolve script is a *copy*
|
|
@@ -390,7 +392,8 @@ def connect(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, require_enabled: bool =
|
|
|
390
392
|
"""
|
|
391
393
|
if require_enabled and not bridge_enabled():
|
|
392
394
|
raise BridgeUnavailable(
|
|
393
|
-
f"
|
|
395
|
+
f"This caller required an explicitly-enabled bridge: set {ENV_ENABLE}=1, "
|
|
396
|
+
"or call connect(require_enabled=False) to use the bridge as a fallback."
|
|
394
397
|
)
|
|
395
398
|
path = config_path()
|
|
396
399
|
try:
|
|
@@ -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
|
+
}
|