davinci-resolve-mcp 2.67.1 → 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 +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- 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,375 @@
|
|
|
1
|
+
"""Differential qualification for the in-app bridge: two transports, one answer.
|
|
2
|
+
|
|
3
|
+
The bridge is a *transport*. It is correct exactly when a call through it is
|
|
4
|
+
indistinguishable from the same call over Blackmagic's own scripting API. That is
|
|
5
|
+
a property you can test mechanically rather than argue about, and this module is
|
|
6
|
+
the machinery for it:
|
|
7
|
+
|
|
8
|
+
1. **Enumerate** the Resolve API surface the MCP tools actually call, by
|
|
9
|
+
intersecting the documented API with what the source really invokes. Testing
|
|
10
|
+
the whole documented API would waste effort on methods we never reach; testing
|
|
11
|
+
an ad-hoc list would miss the ones we do.
|
|
12
|
+
2. **Plan** a call for each method that can be driven without side effects, by
|
|
13
|
+
walking the live object graph from `resolve` outwards.
|
|
14
|
+
3. **Normalise** each result into a comparable form — live objects become their
|
|
15
|
+
shape, not their address, since two transports necessarily mint different
|
|
16
|
+
handles for the same object.
|
|
17
|
+
4. **Diff** the two runs and report, including what could *not* be exercised and
|
|
18
|
+
why. Silent coverage gaps read as "everything passed".
|
|
19
|
+
|
|
20
|
+
## Why the comparison has to run against one Resolve
|
|
21
|
+
|
|
22
|
+
External scripting is Studio-gated, so a free edition can only be reached through
|
|
23
|
+
the bridge — there is no native side to diff against. But Studio speaks *both*,
|
|
24
|
+
so the differential runs there: one application, one project, one object graph,
|
|
25
|
+
two ways in. Anything that differs is the transport's doing, which is the whole
|
|
26
|
+
question. On the free edition the same plan runs bridge-only, which proves
|
|
27
|
+
reachability and shape rather than equivalence.
|
|
28
|
+
|
|
29
|
+
## What "identical" has to tolerate
|
|
30
|
+
|
|
31
|
+
Nothing structural. Handles differ by construction and are normalised away.
|
|
32
|
+
Everything else — types, values, ordering, None-vs-False — is compared exactly,
|
|
33
|
+
because every one of those distinctions has been a real bug in this codebase:
|
|
34
|
+
False-means-failure, None-means-absent, and ordering that a caller relies on.
|
|
35
|
+
|
|
36
|
+
A small set of readings are genuinely time-varying (playhead position, render
|
|
37
|
+
progress). Those are compared by *type only*, and each one is named here rather
|
|
38
|
+
than pattern-matched, so the exemption list stays auditable.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import ast
|
|
44
|
+
import re
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
47
|
+
|
|
48
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
49
|
+
API_DOCS = REPO_ROOT / "docs" / "reference" / "resolve_scripting_api.txt"
|
|
50
|
+
SOURCE_ROOTS = ("src",)
|
|
51
|
+
|
|
52
|
+
#: Readings that legitimately differ between two calls a moment apart. Compared
|
|
53
|
+
#: by type instead of value. Named individually — a regex over "Timecode" or
|
|
54
|
+
#: "Progress" would quietly excuse a real difference in something else.
|
|
55
|
+
VOLATILE_METHODS = frozenset({
|
|
56
|
+
"GetCurrentTimecode",
|
|
57
|
+
"GetCurrentVideoItem",
|
|
58
|
+
"GetCurrentVideoTimecode",
|
|
59
|
+
"IsRenderingInProgress",
|
|
60
|
+
"GetRenderJobStatus",
|
|
61
|
+
"IsPlayheadOnVideoFrame",
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
#: Never invoked by the harness, whatever the enumeration says. These either
|
|
65
|
+
#: change the project, touch the filesystem, or block on UI. The write paths are
|
|
66
|
+
#: exercised by named scenarios instead, where the setup and teardown are
|
|
67
|
+
#: explicit and a failure can be cleaned up.
|
|
68
|
+
_UNSAFE_VERBS = (
|
|
69
|
+
"Delete", "Remove", "Create", "Add", "Set", "Import", "Export", "Save",
|
|
70
|
+
"Load", "Append", "Insert", "Paste", "Copy", "Duplicate", "Move", "Render",
|
|
71
|
+
"Start", "Stop", "Open", "Close", "Quit", "Restore", "Archive", "Refresh",
|
|
72
|
+
"Transcribe", "Apply", "Link", "Unlink", "Relink", "Replace", "Convert",
|
|
73
|
+
"Detect", "Generate", "Auto", "Reset", "Clear", "Enable", "Disable",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_CLASS_HEADING_RE = re.compile(r"^([A-Z][A-Za-z]+)\s*$")
|
|
77
|
+
_METHOD_RE = re.compile(r"^\s+([A-Z][A-Za-z0-9_]+)\s*\(")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── 1. what the tools actually call ──────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def documented_methods(docs_path: Path = API_DOCS) -> Dict[str, Set[str]]:
|
|
84
|
+
"""`{class_name: {method, ...}}` from Blackmagic's own API reference.
|
|
85
|
+
|
|
86
|
+
Deprecated and unsupported sections are cut: a bridge is not obliged to carry
|
|
87
|
+
calls the vendor has withdrawn, and including them would report failures that
|
|
88
|
+
say nothing about the transport.
|
|
89
|
+
"""
|
|
90
|
+
classes: Dict[str, Set[str]] = {}
|
|
91
|
+
text = docs_path.read_text(encoding="utf-8", errors="replace")
|
|
92
|
+
for marker in ("\nDeprecated Resolve API Functions", "\nUnsupported Resolve API Functions"):
|
|
93
|
+
index = text.find(marker)
|
|
94
|
+
if index != -1:
|
|
95
|
+
text = text[:index]
|
|
96
|
+
current: Optional[str] = None
|
|
97
|
+
for line in text.splitlines():
|
|
98
|
+
if not line.strip():
|
|
99
|
+
continue
|
|
100
|
+
if not line.startswith(" "):
|
|
101
|
+
match = _CLASS_HEADING_RE.match(line)
|
|
102
|
+
current = match.group(1) if match and len(match.group(1).split()) == 1 else None
|
|
103
|
+
continue
|
|
104
|
+
if current is None:
|
|
105
|
+
continue
|
|
106
|
+
method = _METHOD_RE.match(line)
|
|
107
|
+
if method:
|
|
108
|
+
classes.setdefault(current, set()).add(method.group(1))
|
|
109
|
+
return classes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def called_methods(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Set[str]]:
|
|
113
|
+
"""`{method: {file, ...}}` — Resolve API methods this codebase invokes.
|
|
114
|
+
|
|
115
|
+
AST rather than grep: a method name inside a docstring, a log line or a tool
|
|
116
|
+
description is not a call, and counting it would inflate the surface the
|
|
117
|
+
bridge is asked to carry.
|
|
118
|
+
"""
|
|
119
|
+
documented = {m for methods in documented_methods().values() for m in methods}
|
|
120
|
+
found: Dict[str, Set[str]] = {}
|
|
121
|
+
for root in roots:
|
|
122
|
+
for path in sorted((REPO_ROOT / root).rglob("*.py")):
|
|
123
|
+
try:
|
|
124
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
125
|
+
except SyntaxError: # pragma: no cover - a broken file is its own alarm
|
|
126
|
+
continue
|
|
127
|
+
relative = str(path.relative_to(REPO_ROOT))
|
|
128
|
+
for node in ast.walk(tree):
|
|
129
|
+
if not isinstance(node, ast.Call):
|
|
130
|
+
continue
|
|
131
|
+
target = node.func
|
|
132
|
+
name = None
|
|
133
|
+
if isinstance(target, ast.Attribute):
|
|
134
|
+
name = target.attr
|
|
135
|
+
elif isinstance(target, ast.Name):
|
|
136
|
+
name = target.id
|
|
137
|
+
if name in documented:
|
|
138
|
+
found.setdefault(name, set()).add(relative)
|
|
139
|
+
# getattr(obj, "SetLUT", None) is a capability check, and the
|
|
140
|
+
# bridge has to answer it as truthfully as a call.
|
|
141
|
+
if isinstance(target, ast.Name) and target.id == "getattr" and node.args[1:]:
|
|
142
|
+
probe = node.args[1]
|
|
143
|
+
if isinstance(probe, ast.Constant) and probe.value in documented:
|
|
144
|
+
found.setdefault(probe.value, set()).add(relative)
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_safe_to_probe(method: str) -> bool:
|
|
149
|
+
"""Can the harness call this blind, on a project it does not own?
|
|
150
|
+
|
|
151
|
+
Read-only by name is a weak test in general — but here it is backed by the
|
|
152
|
+
rule that anything failing it is not skipped, it is routed to a named
|
|
153
|
+
scenario with explicit setup and teardown. The conservative answer costs
|
|
154
|
+
coverage in one place and buys it back in the other.
|
|
155
|
+
"""
|
|
156
|
+
if method.startswith(_UNSAFE_VERBS):
|
|
157
|
+
return False
|
|
158
|
+
return method.startswith(("Get", "Is", "Has", "List", "Find", "Count"))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
|
|
162
|
+
"""The enumeration, split into what can be probed and what cannot."""
|
|
163
|
+
calls = called_methods(roots)
|
|
164
|
+
safe = sorted(m for m in calls if is_safe_to_probe(m))
|
|
165
|
+
unsafe = sorted(m for m in calls if not is_safe_to_probe(m))
|
|
166
|
+
return {
|
|
167
|
+
"called": sorted(calls),
|
|
168
|
+
"safe_to_probe": safe,
|
|
169
|
+
"needs_a_scenario": unsafe,
|
|
170
|
+
"sources": {m: sorted(files) for m, files in calls.items()},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── 2. walking the live object graph ─────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
#: How the harness reaches each object worth probing, as a chain of zero-argument
|
|
178
|
+
#: calls from the Resolve root. Kept declarative so the same walk runs over either
|
|
179
|
+
#: transport without the driver knowing which it holds.
|
|
180
|
+
#: `("[]", (index,))` takes an element of a returned list. Without it the walk
|
|
181
|
+
#: stops at the collection accessors and never reaches TimelineItem,
|
|
182
|
+
#: MediaPoolItem or Graph — which between them carry most of the read surface
|
|
183
|
+
#: worth qualifying (LUTs, node graphs, source timings, metadata).
|
|
184
|
+
_INDEX = "[]"
|
|
185
|
+
|
|
186
|
+
_PM = ("GetProjectManager", ())
|
|
187
|
+
_PROJECT = (_PM, ("GetCurrentProject", ()))
|
|
188
|
+
_POOL = _PROJECT + (("GetMediaPool", ()),)
|
|
189
|
+
_TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
|
|
190
|
+
_TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)), (_INDEX, (0,)))
|
|
191
|
+
_GALLERY = _PROJECT + (("GetGallery", ()),)
|
|
192
|
+
|
|
193
|
+
OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
|
|
194
|
+
("resolve", ()),
|
|
195
|
+
("project_manager", (_PM,)),
|
|
196
|
+
("project", _PROJECT),
|
|
197
|
+
("media_pool", _POOL),
|
|
198
|
+
("root_folder", _POOL + (("GetRootFolder", ()),)),
|
|
199
|
+
("media_pool_item", _POOL + (("GetRootFolder", ()), ("GetClipList", ()), (_INDEX, (0,)))),
|
|
200
|
+
("timeline", _TIMELINE),
|
|
201
|
+
("timeline_node_graph", _TIMELINE + (("GetNodeGraph", ()),)),
|
|
202
|
+
("timeline_item", _TIMELINE_ITEM),
|
|
203
|
+
("timeline_item_node_graph", _TIMELINE_ITEM + (("GetNodeGraph", ()),)),
|
|
204
|
+
# Unreachable until a project actually has a colour group. Listed anyway so
|
|
205
|
+
# its three methods report as "no such object here" rather than vanishing
|
|
206
|
+
# from the coverage count with no explanation.
|
|
207
|
+
("color_group", _PROJECT + (("GetColorGroupsList", ()), (_INDEX, (0,)))),
|
|
208
|
+
("gallery", _GALLERY),
|
|
209
|
+
("gallery_still_album", _GALLERY + (("GetCurrentStillAlbum", ()),)),
|
|
210
|
+
("media_storage", (("GetMediaStorage", ()),)),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
|
|
215
|
+
"""Follow a chain from the Resolve root, returning None if it breaks.
|
|
216
|
+
|
|
217
|
+
A missing link is a legitimate state — no project open, no current timeline,
|
|
218
|
+
an empty track — and must not read as a transport failure, so it returns None
|
|
219
|
+
rather than raising. The distinction matters: the report separates "this
|
|
220
|
+
object was unreachable" from "this method disagreed".
|
|
221
|
+
"""
|
|
222
|
+
current = resolve
|
|
223
|
+
for method, args in chain:
|
|
224
|
+
if current is None:
|
|
225
|
+
return None
|
|
226
|
+
if method == _INDEX:
|
|
227
|
+
index = args[0]
|
|
228
|
+
if not isinstance(current, (list, tuple)) or len(current) <= index:
|
|
229
|
+
return None
|
|
230
|
+
current = current[index]
|
|
231
|
+
continue
|
|
232
|
+
function = getattr(current, method, None)
|
|
233
|
+
if function is None:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
current = function(*args)
|
|
237
|
+
except Exception: # noqa: BLE001 - an unreachable branch, not a verdict
|
|
238
|
+
return None
|
|
239
|
+
return current
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── 3. normalising a result so two transports can be compared ────────────────
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def normalise(value: Any, *, depth: int = 0) -> Any:
|
|
246
|
+
"""Reduce a result to what two transports must agree on.
|
|
247
|
+
|
|
248
|
+
Live objects become a marker rather than a value: the bridge returns a
|
|
249
|
+
handle, native scripting returns a `PyRemoteObject`, and neither address says
|
|
250
|
+
anything about correctness. Everything else is preserved exactly — including
|
|
251
|
+
the difference between None and False, which this codebase has been bitten by
|
|
252
|
+
often enough to make it a compared value rather than a detail.
|
|
253
|
+
"""
|
|
254
|
+
if value is None or isinstance(value, (bool, int, str)):
|
|
255
|
+
return value
|
|
256
|
+
if isinstance(value, float):
|
|
257
|
+
# Two transports serialise the same double identically; rounding only
|
|
258
|
+
# guards against a JSON round trip introducing a last-bit difference.
|
|
259
|
+
return round(value, 9)
|
|
260
|
+
if depth > 6:
|
|
261
|
+
return "<deep>"
|
|
262
|
+
if isinstance(value, (list, tuple)):
|
|
263
|
+
return [normalise(item, depth=depth + 1) for item in value]
|
|
264
|
+
if isinstance(value, dict):
|
|
265
|
+
return {str(k): normalise(v, depth=depth + 1) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
|
|
266
|
+
return "<object>"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def describe(value: Any) -> str:
|
|
270
|
+
"""A type label that survives the transport difference."""
|
|
271
|
+
if value is None:
|
|
272
|
+
return "None"
|
|
273
|
+
if isinstance(value, bool):
|
|
274
|
+
return "bool"
|
|
275
|
+
if isinstance(value, (list, tuple)):
|
|
276
|
+
return "list[%d]" % len(value)
|
|
277
|
+
if isinstance(value, dict):
|
|
278
|
+
return "dict[%d]" % len(value)
|
|
279
|
+
if isinstance(value, (int, float, str)):
|
|
280
|
+
return type(value).__name__
|
|
281
|
+
return "object"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── 4. running and diffing ───────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def probe_one(target: Any, method: str) -> Dict[str, Any]:
|
|
288
|
+
"""Call one method, recording the outcome rather than propagating it.
|
|
289
|
+
|
|
290
|
+
A raising method is a *result* here, not an abort: whether both transports
|
|
291
|
+
raise the same way is exactly what the diff is for.
|
|
292
|
+
"""
|
|
293
|
+
function = getattr(target, method, None)
|
|
294
|
+
if function is None:
|
|
295
|
+
return {"outcome": "absent"}
|
|
296
|
+
try:
|
|
297
|
+
value = function()
|
|
298
|
+
except Exception as exc: # noqa: BLE001 - the exception IS the observation
|
|
299
|
+
return {"outcome": "raised", "error": type(exc).__name__, "message": str(exc)[:200]}
|
|
300
|
+
return {"outcome": "returned", "value": normalise(value), "type": describe(value)}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]]:
|
|
304
|
+
"""Every enumerated safe method, against every object that has it.
|
|
305
|
+
|
|
306
|
+
Keyed `object.method` so the diff can say *where* two transports disagreed,
|
|
307
|
+
not merely that they did.
|
|
308
|
+
"""
|
|
309
|
+
results: Dict[str, Dict[str, Any]] = {}
|
|
310
|
+
for label, chain in OBJECT_GRAPH:
|
|
311
|
+
target = walk(resolve, chain)
|
|
312
|
+
if target is None:
|
|
313
|
+
results[f"{label}.<unreachable>"] = {"outcome": "unreachable"}
|
|
314
|
+
continue
|
|
315
|
+
for method in methods:
|
|
316
|
+
if getattr(target, method, None) is None:
|
|
317
|
+
continue # not on this object; absence is reported by the diff
|
|
318
|
+
results[f"{label}.{method}"] = probe_one(target, method)
|
|
319
|
+
return results
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
323
|
+
"""Diff two probe runs. Empty `differences` is the pass condition."""
|
|
324
|
+
differences: List[Dict[str, Any]] = []
|
|
325
|
+
for key in sorted(set(native) | set(bridged)):
|
|
326
|
+
left, right = native.get(key), bridged.get(key)
|
|
327
|
+
if left is None or right is None:
|
|
328
|
+
differences.append({
|
|
329
|
+
"key": key, "kind": "only_one_transport_reached_it",
|
|
330
|
+
"native": left, "bridge": right,
|
|
331
|
+
})
|
|
332
|
+
continue
|
|
333
|
+
method = key.rsplit(".", 1)[-1]
|
|
334
|
+
if method in VOLATILE_METHODS:
|
|
335
|
+
# Value moves on its own; the transport is still on the hook for
|
|
336
|
+
# returning the same *kind* of thing.
|
|
337
|
+
if left.get("type") != right.get("type") or left["outcome"] != right["outcome"]:
|
|
338
|
+
differences.append({"key": key, "kind": "volatile_type_mismatch",
|
|
339
|
+
"native": left.get("type"), "bridge": right.get("type")})
|
|
340
|
+
continue
|
|
341
|
+
if left != right:
|
|
342
|
+
differences.append({"key": key, "kind": "value_mismatch",
|
|
343
|
+
"native": left, "bridge": right})
|
|
344
|
+
agreed = len(set(native) & set(bridged)) - len(
|
|
345
|
+
[d for d in differences if d["kind"] != "only_one_transport_reached_it"]
|
|
346
|
+
)
|
|
347
|
+
return {
|
|
348
|
+
"compared": len(set(native) | set(bridged)),
|
|
349
|
+
"agreed": agreed,
|
|
350
|
+
"differences": differences,
|
|
351
|
+
"identical": not differences,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def coverage_report(surface: Dict[str, Any], results: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
356
|
+
"""What the run actually exercised — and what it did not, with the reason.
|
|
357
|
+
|
|
358
|
+
A harness that reports only its passes reads as complete when it is not, so
|
|
359
|
+
the untouched methods are named. Most of them are writes routed to scenarios;
|
|
360
|
+
the rest are methods no reachable object carried.
|
|
361
|
+
"""
|
|
362
|
+
exercised = {key.rsplit(".", 1)[-1] for key in results if not key.endswith(".<unreachable>")}
|
|
363
|
+
safe = set(surface["safe_to_probe"])
|
|
364
|
+
unreachable = sorted(key.split(".", 1)[0] for key in results if key.endswith(".<unreachable>"))
|
|
365
|
+
return {
|
|
366
|
+
"called_by_tools": len(surface["called"]),
|
|
367
|
+
"safe_to_probe": len(safe),
|
|
368
|
+
"exercised": len(safe & exercised),
|
|
369
|
+
"not_exercised": sorted(safe - exercised),
|
|
370
|
+
# Named, because "this project has no colour group" and "the bridge
|
|
371
|
+
# cannot reach colour groups" are different findings and a bare count
|
|
372
|
+
# of exercised methods conflates them.
|
|
373
|
+
"unreachable_objects": unreachable,
|
|
374
|
+
"needs_a_scenario": surface["needs_a_scenario"],
|
|
375
|
+
}
|