motionloom 2.3.0 → 2.4.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 +24 -1
- package/README.md +78 -11
- package/ROADMAP.md +8 -5
- package/SKILL.md +24 -5
- package/agent-card.json +31 -5
- package/agent-surfaces.json +1 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +29 -5
- package/docs/CHECKLIST.md +16 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.4.0.md +31 -0
- package/docs/releases/npm-publish-from-workstation.md +19 -8
- package/docs/research/agent-skill-ecosystem-notes.md +47 -0
- package/docs/research/ai-animation-tools-2026-notes.md +81 -0
- package/docs/research/ai-animation-tools-2026-report.md +168 -0
- package/docs/research/ai-pilot-ingest-notes.md +29 -0
- package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
- package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
- package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
- package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
- package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
- package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
- package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
- package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
- package/examples/agent-consumer/rive-package-gate/README.md +20 -0
- package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
- package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
- package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
- package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
- package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
- package/examples/agent-consumer/runtime-pilot/export.json +17 -0
- package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
- package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
- package/package.json +56 -4
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +22 -1
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +1 -1
- package/schemas/atlas-contract.schema.json +48 -0
- package/schemas/control-track.schema.json +18 -0
- package/schemas/export-manifest.schema.json +19 -0
- package/schemas/frame-geometry.schema.json +79 -0
- package/schemas/generation-receipt.schema.json +20 -0
- package/schemas/layered-map.schema.json +75 -0
- package/schemas/rig-adapter-registry.schema.json +14 -0
- package/schemas/rig-compatibility.schema.json +53 -0
- package/schemas/rive-package-manifest.schema.json +43 -0
- package/schemas/runtime-candidate.schema.json +48 -0
- package/schemas/scene-manifest.schema.json +18 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +8 -3
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/quality-gate.py +88 -2
- package/scripts/report.py +85 -0
- package/scripts/resolve-task-bundle.py +84 -0
- package/scripts/review-hook.py +26 -4
- package/scripts/rig-compatibility.py +251 -0
- package/scripts/rive-package-gate.py +245 -0
- package/scripts/runtime-candidate.py +301 -0
- package/scripts/setup.mjs +54 -26
- package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
- package/src/output/runtime-pilot-framer/browser-review.json +20 -0
- package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
- package/src/output/runtime-pilot-framer/manifest.json +31 -0
- package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
- package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
- package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
- package/src/output/runtime-pilot-framer/scene.jsx +52 -0
- package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
- package/tests/scripts/run_tests.py +107 -0
- package/tests/scripts/test_ai_pilot_builder.py +128 -0
- package/tests/scripts/test_alpha_isolation.py +60 -0
- package/tests/scripts/test_artifact_intake.py +146 -0
- package/tests/scripts/test_asset_consistency.py +199 -0
- package/tests/scripts/test_rig_compatibility.py +105 -0
- package/tests/scripts/test_rive_package_gate.py +101 -0
- package/tests/scripts/test_runtime_candidate.py +95 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MotionLoom Asset Consistency Compiler.
|
|
4
|
+
|
|
5
|
+
Style: Timeline Desk — deterministic measurements first, explicit evidence
|
|
6
|
+
second, heuristic warnings last. This tool never grants provenance authority,
|
|
7
|
+
production eligibility or human approval.
|
|
8
|
+
|
|
9
|
+
The analyzer intentionally uses only the Python standard library. It reads
|
|
10
|
+
uncompressed PNG pixels, validates contract geometry, emits machine-readable
|
|
11
|
+
evidence, and exits non-zero for deterministic contract violations.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
import zlib
|
|
23
|
+
from dataclasses import dataclass, asdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Iterable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Issue:
|
|
33
|
+
severity: str
|
|
34
|
+
code: str
|
|
35
|
+
message: str
|
|
36
|
+
path: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PNGError(ValueError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PNGImage:
|
|
44
|
+
"""Small deterministic PNG reader for the formats needed by contracts."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: Path):
|
|
47
|
+
self.path = path
|
|
48
|
+
self.width = 0
|
|
49
|
+
self.height = 0
|
|
50
|
+
self.bit_depth = 0
|
|
51
|
+
self.color_type = 0
|
|
52
|
+
self._rows: list[bytes] = []
|
|
53
|
+
self._rgba_cache: list[tuple[int, int, int, int]] | None = None
|
|
54
|
+
self._palette = b""
|
|
55
|
+
self._transparency = b""
|
|
56
|
+
self._read()
|
|
57
|
+
|
|
58
|
+
def _read(self) -> None:
|
|
59
|
+
data = self.path.read_bytes()
|
|
60
|
+
if not data.startswith(PNG_SIGNATURE):
|
|
61
|
+
raise PNGError(f"not a PNG file: {self.path}")
|
|
62
|
+
offset = len(PNG_SIGNATURE)
|
|
63
|
+
idat = bytearray()
|
|
64
|
+
interlace = 0
|
|
65
|
+
while offset < len(data):
|
|
66
|
+
if offset + 12 > len(data):
|
|
67
|
+
raise PNGError(f"truncated PNG chunk: {self.path}")
|
|
68
|
+
length = struct.unpack(">I", data[offset : offset + 4])[0]
|
|
69
|
+
chunk_type = data[offset + 4 : offset + 8]
|
|
70
|
+
start = offset + 8
|
|
71
|
+
end = start + length
|
|
72
|
+
if end + 4 > len(data):
|
|
73
|
+
raise PNGError(f"truncated PNG data: {self.path}")
|
|
74
|
+
payload = data[start:end]
|
|
75
|
+
offset = end + 4
|
|
76
|
+
if chunk_type == b"IHDR":
|
|
77
|
+
if length != 13:
|
|
78
|
+
raise PNGError("invalid IHDR length")
|
|
79
|
+
self.width, self.height, self.bit_depth, self.color_type, compression, filtering, interlace = struct.unpack(
|
|
80
|
+
">IIBBBBB", payload
|
|
81
|
+
)
|
|
82
|
+
if compression != 0 or filtering != 0 or interlace != 0:
|
|
83
|
+
raise PNGError("only non-interlaced baseline PNG is supported")
|
|
84
|
+
if self.bit_depth != 8 or self.color_type not in (0, 2, 3, 4, 6):
|
|
85
|
+
raise PNGError("only 8-bit grayscale/RGB/RGBA PNG is supported")
|
|
86
|
+
elif chunk_type == b"IDAT":
|
|
87
|
+
idat.extend(payload)
|
|
88
|
+
elif chunk_type == b"PLTE":
|
|
89
|
+
self._palette = payload
|
|
90
|
+
elif chunk_type == b"tRNS":
|
|
91
|
+
self._transparency = payload
|
|
92
|
+
elif chunk_type == b"IEND":
|
|
93
|
+
break
|
|
94
|
+
if not self.width or not self.height or not idat:
|
|
95
|
+
raise PNGError(f"PNG is missing IHDR or IDAT: {self.path}")
|
|
96
|
+
raw = zlib.decompress(bytes(idat))
|
|
97
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[self.color_type]
|
|
98
|
+
stride = self.width * channels
|
|
99
|
+
expected = self.height * (stride + 1)
|
|
100
|
+
if len(raw) != expected:
|
|
101
|
+
raise PNGError(f"unexpected decompressed PNG length for {self.path}")
|
|
102
|
+
rows: list[bytes] = []
|
|
103
|
+
previous = bytearray(stride)
|
|
104
|
+
cursor = 0
|
|
105
|
+
for _ in range(self.height):
|
|
106
|
+
filter_type = raw[cursor]
|
|
107
|
+
cursor += 1
|
|
108
|
+
filtered = bytearray(raw[cursor : cursor + stride])
|
|
109
|
+
cursor += stride
|
|
110
|
+
current = bytearray(stride)
|
|
111
|
+
for i, value in enumerate(filtered):
|
|
112
|
+
left = current[i - channels] if i >= channels else 0
|
|
113
|
+
up = previous[i]
|
|
114
|
+
up_left = previous[i - channels] if i >= channels else 0
|
|
115
|
+
if filter_type == 0:
|
|
116
|
+
result = value
|
|
117
|
+
elif filter_type == 1:
|
|
118
|
+
result = (value + left) & 0xFF
|
|
119
|
+
elif filter_type == 2:
|
|
120
|
+
result = (value + up) & 0xFF
|
|
121
|
+
elif filter_type == 3:
|
|
122
|
+
result = (value + ((left + up) // 2)) & 0xFF
|
|
123
|
+
elif filter_type == 4:
|
|
124
|
+
result = (value + _paeth(left, up, up_left)) & 0xFF
|
|
125
|
+
else:
|
|
126
|
+
raise PNGError(f"unsupported PNG filter type {filter_type}")
|
|
127
|
+
current[i] = result
|
|
128
|
+
rows.append(bytes(current))
|
|
129
|
+
previous = current
|
|
130
|
+
self._rows = rows
|
|
131
|
+
|
|
132
|
+
def rgba(self) -> list[tuple[int, int, int, int]]:
|
|
133
|
+
if self._rgba_cache is not None:
|
|
134
|
+
return self._rgba_cache
|
|
135
|
+
result: list[tuple[int, int, int, int]] = []
|
|
136
|
+
for row in self._rows:
|
|
137
|
+
if self.color_type == 6:
|
|
138
|
+
result.extend(tuple(row[i : i + 4]) for i in range(0, len(row), 4))
|
|
139
|
+
elif self.color_type == 4:
|
|
140
|
+
result.extend((v, v, v, row[i + 1]) for i, v in enumerate(row[::2]))
|
|
141
|
+
elif self.color_type == 2:
|
|
142
|
+
result.extend((row[i], row[i + 1], row[i + 2], 255) for i in range(0, len(row), 3))
|
|
143
|
+
elif self.color_type == 3:
|
|
144
|
+
if len(self._palette) % 3 != 0:
|
|
145
|
+
raise PNGError(f"invalid PNG palette: {self.path}")
|
|
146
|
+
for palette_index in row:
|
|
147
|
+
offset = palette_index * 3
|
|
148
|
+
if offset + 2 >= len(self._palette):
|
|
149
|
+
raise PNGError(f"PNG palette index out of range: {self.path}")
|
|
150
|
+
alpha = self._transparency[palette_index] if palette_index < len(self._transparency) else 255
|
|
151
|
+
result.append((self._palette[offset], self._palette[offset + 1], self._palette[offset + 2], alpha))
|
|
152
|
+
else:
|
|
153
|
+
result.extend((v, v, v, 255) for v in row)
|
|
154
|
+
self._rgba_cache = result
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
def alpha_bbox(self, threshold: int = 1, rect: dict[str, Any] | None = None) -> dict[str, int] | None:
|
|
158
|
+
bounds = _normalise_rect(rect, self.width, self.height) if rect else {"x": 0, "y": 0, "width": self.width, "height": self.height}
|
|
159
|
+
pixels = self.rgba()
|
|
160
|
+
min_x, min_y = self.width, self.height
|
|
161
|
+
max_x, max_y = -1, -1
|
|
162
|
+
for y in range(bounds["y"], bounds["y"] + bounds["height"]):
|
|
163
|
+
for x in range(bounds["x"], bounds["x"] + bounds["width"]):
|
|
164
|
+
if pixels[y * self.width + x][3] >= threshold:
|
|
165
|
+
min_x, min_y = min(min_x, x), min(min_y, y)
|
|
166
|
+
max_x, max_y = max(max_x, x), max(max_y, y)
|
|
167
|
+
if max_x < 0:
|
|
168
|
+
return None
|
|
169
|
+
return {"x": min_x, "y": min_y, "width": max_x - min_x + 1, "height": max_y - min_y + 1}
|
|
170
|
+
|
|
171
|
+
def alpha_count(self, threshold: int = 1, rect: dict[str, Any] | None = None) -> int:
|
|
172
|
+
bounds = _normalise_rect(rect, self.width, self.height) if rect else {"x": 0, "y": 0, "width": self.width, "height": self.height}
|
|
173
|
+
pixels = self.rgba()
|
|
174
|
+
return sum(
|
|
175
|
+
1
|
|
176
|
+
for y in range(bounds["y"], bounds["y"] + bounds["height"])
|
|
177
|
+
for x in range(bounds["x"], bounds["x"] + bounds["width"])
|
|
178
|
+
if pixels[y * self.width + x][3] >= threshold
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def opaque_outside(self, rect: dict[str, Any], threshold: int = 1) -> int:
|
|
182
|
+
bounds = _normalise_rect(rect, self.width, self.height)
|
|
183
|
+
pixels = self.rgba()
|
|
184
|
+
return sum(
|
|
185
|
+
1
|
|
186
|
+
for y in range(self.height)
|
|
187
|
+
for x in range(self.width)
|
|
188
|
+
if pixels[y * self.width + x][3] >= threshold
|
|
189
|
+
and not (bounds["x"] <= x < bounds["x"] + bounds["width"] and bounds["y"] <= y < bounds["y"] + bounds["height"])
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def edge_difference(self, axis: str) -> float:
|
|
193
|
+
pixels = self.rgba()
|
|
194
|
+
if axis == "x":
|
|
195
|
+
pairs = ((pixels[y * self.width], pixels[y * self.width + self.width - 1]) for y in range(self.height))
|
|
196
|
+
else:
|
|
197
|
+
pairs = ((pixels[x], pixels[(self.height - 1) * self.width + x]) for x in range(self.width))
|
|
198
|
+
differences = [sum(abs(a[i] - b[i]) for i in range(4)) / (4 * 255) for a, b in pairs]
|
|
199
|
+
return sum(differences) / len(differences) if differences else 0.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _paeth(a: int, b: int, c: int) -> int:
|
|
203
|
+
p = a + b - c
|
|
204
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
205
|
+
return a if pa <= pb and pa <= pc else b if pb <= pc else c
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _normalise_rect(value: dict[str, Any] | None, width: int, height: int) -> dict[str, int]:
|
|
209
|
+
value = value or {"x": 0, "y": 0, "width": width, "height": height}
|
|
210
|
+
return {key: int(value[key]) for key in ("x", "y", "width", "height")}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _rect_inside(rect: dict[str, int], width: int, height: int) -> bool:
|
|
214
|
+
return rect["x"] >= 0 and rect["y"] >= 0 and rect["width"] > 0 and rect["height"] > 0 and rect["x"] + rect["width"] <= width and rect["y"] + rect["height"] <= height
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _rect_overlap(a: dict[str, int], b: dict[str, int]) -> bool:
|
|
218
|
+
return not (
|
|
219
|
+
a["x"] + a["width"] <= b["x"]
|
|
220
|
+
or b["x"] + b["width"] <= a["x"]
|
|
221
|
+
or a["y"] + a["height"] <= b["y"]
|
|
222
|
+
or b["y"] + b["height"] <= a["y"]
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _close(a: float, b: float, tolerance: float) -> bool:
|
|
227
|
+
return abs(a - b) <= tolerance
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _load_document(path: Path) -> tuple[dict[str, Any] | None, list[Issue]]:
|
|
231
|
+
try:
|
|
232
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
233
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
234
|
+
return None, [Issue("error", "invalid_json", str(exc), str(path))]
|
|
235
|
+
if not isinstance(value, dict):
|
|
236
|
+
return None, [Issue("error", "invalid_document", "contract root must be an object", str(path))]
|
|
237
|
+
return value, []
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _required(document: dict[str, Any], keys: Iterable[str]) -> list[Issue]:
|
|
241
|
+
return [Issue("error", "missing_field", f"required field is missing: {key}", key) for key in keys if key not in document]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve(root: Path, path_value: str) -> Path:
|
|
245
|
+
path = Path(path_value)
|
|
246
|
+
return path if path.is_absolute() else root / path
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def validate_identity(document: dict[str, Any]) -> dict[str, Any]:
|
|
250
|
+
issues = _required(document, ("schema_version", "asset_id", "asset_kind", "identity", "derivation"))
|
|
251
|
+
if document.get("schema_version") != "0.1":
|
|
252
|
+
issues.append(Issue("error", "schema_version", "asset identity schema_version must be 0.1", "schema_version"))
|
|
253
|
+
identity = document.get("identity") if isinstance(document.get("identity"), dict) else {}
|
|
254
|
+
derivation = document.get("derivation") if isinstance(document.get("derivation"), dict) else {}
|
|
255
|
+
issues.extend(_required(identity, ("subject_id", "reference_hashes", "camera", "coordinate_system", "scale", "pivot", "palette_lock")))
|
|
256
|
+
issues.extend(_required(derivation, ("origin", "source_refs")))
|
|
257
|
+
generator = derivation.get("generator") if isinstance(derivation.get("generator"), dict) else {}
|
|
258
|
+
origin = derivation.get("origin")
|
|
259
|
+
if origin not in {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed", "artist_authored", "code_authored", "unknown"}:
|
|
260
|
+
issues.append(Issue("error", "invalid_origin", "asset origin is not a supported provenance tier", "derivation.origin"))
|
|
261
|
+
if origin in {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed"}:
|
|
262
|
+
issues.extend(_required(generator, ("model", "task_id", "prompt_hash")))
|
|
263
|
+
if origin == "unknown":
|
|
264
|
+
issues.append(Issue("error", "unknown_origin", "unknown asset origin is blocked", "derivation.origin"))
|
|
265
|
+
return _result("asset_identity", issues, {"asset_id": document.get("asset_id"), "origin": derivation.get("origin")})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def validate_action_set(document: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
issues = _required(document, ("schema_version", "asset_identity", "actions", "invariants"))
|
|
270
|
+
if document.get("schema_version") != "0.1":
|
|
271
|
+
issues.append(Issue("error", "schema_version", "action set schema_version must be 0.1", "schema_version"))
|
|
272
|
+
actions = document.get("actions") if isinstance(document.get("actions"), list) else []
|
|
273
|
+
seen: set[str] = set()
|
|
274
|
+
for index, action in enumerate(actions):
|
|
275
|
+
if not isinstance(action, dict):
|
|
276
|
+
issues.append(Issue("error", "invalid_action", "action must be an object", f"actions[{index}]"))
|
|
277
|
+
continue
|
|
278
|
+
action_id = action.get("action_id")
|
|
279
|
+
if action_id in seen:
|
|
280
|
+
issues.append(Issue("error", "duplicate_action", f"duplicate action_id: {action_id}", f"actions[{index}].action_id"))
|
|
281
|
+
seen.add(str(action_id))
|
|
282
|
+
if not action.get("frames"):
|
|
283
|
+
issues.append(Issue("error", "empty_action", "action must contain at least one frame", f"actions[{index}].frames"))
|
|
284
|
+
elif not isinstance(action.get("frames"), list) or any(not isinstance(frame, str) or not frame for frame in action["frames"]):
|
|
285
|
+
issues.append(Issue("error", "invalid_frames", "frames must be a non-empty array of paths", f"actions[{index}].frames"))
|
|
286
|
+
if not isinstance(action.get("fps"), (int, float)) or action.get("fps", 0) <= 0:
|
|
287
|
+
issues.append(Issue("error", "invalid_fps", "fps must be greater than zero", f"actions[{index}].fps"))
|
|
288
|
+
if not isinstance(action.get("loop"), bool):
|
|
289
|
+
issues.append(Issue("error", "invalid_loop", "loop must be an explicit boolean", f"actions[{index}].loop"))
|
|
290
|
+
return _result("action_set", issues, {"action_count": len(actions), "asset_identity": document.get("asset_identity")})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def validate_frame_geometry(document: dict[str, Any], root: Path) -> dict[str, Any]:
|
|
294
|
+
issues = _required(document, ("schema_version", "asset_identity", "canvas", "invariants", "frames"))
|
|
295
|
+
if document.get("schema_version") != "0.1":
|
|
296
|
+
issues.append(Issue("error", "schema_version", "frame geometry schema_version must be 0.1", "schema_version"))
|
|
297
|
+
canvas = document.get("canvas") if isinstance(document.get("canvas"), dict) else {}
|
|
298
|
+
invariants = document.get("invariants") if isinstance(document.get("invariants"), dict) else {}
|
|
299
|
+
frames = document.get("frames") if isinstance(document.get("frames"), list) else []
|
|
300
|
+
width, height = int(canvas.get("width", 0) or 0), int(canvas.get("height", 0) or 0)
|
|
301
|
+
pivot_tolerance = float(invariants.get("pivot_tolerance_px", 0) or 0)
|
|
302
|
+
footline_tolerance = float(invariants.get("footline_tolerance_px", 0) or 0)
|
|
303
|
+
bbox_tolerance = float(invariants.get("bbox_drift_tolerance_px", 0) or 0)
|
|
304
|
+
min_alpha = int(invariants.get("min_alpha_pixels", 1) or 1)
|
|
305
|
+
measurements: list[dict[str, Any]] = []
|
|
306
|
+
base_pivot: tuple[float, float] | None = None
|
|
307
|
+
base_footline: float | None = None
|
|
308
|
+
base_bbox: dict[str, int] | None = None
|
|
309
|
+
for index, frame in enumerate(frames):
|
|
310
|
+
path_prefix = f"frames[{index}]"
|
|
311
|
+
if not isinstance(frame, dict):
|
|
312
|
+
issues.append(Issue("error", "invalid_frame", "frame must be an object", path_prefix))
|
|
313
|
+
continue
|
|
314
|
+
image_path = _resolve(root, str(frame.get("image", "")))
|
|
315
|
+
try:
|
|
316
|
+
image = PNGImage(image_path)
|
|
317
|
+
except (OSError, PNGError, ValueError) as exc:
|
|
318
|
+
issues.append(Issue("error", "image_unreadable", str(exc), f"{path_prefix}.image"))
|
|
319
|
+
continue
|
|
320
|
+
rect = _normalise_rect(frame.get("rect"), image.width, image.height)
|
|
321
|
+
if not _rect_inside(rect, image.width, image.height):
|
|
322
|
+
issues.append(Issue("error", "rect_out_of_bounds", "frame rect is outside the image", f"{path_prefix}.rect"))
|
|
323
|
+
continue
|
|
324
|
+
if width != image.width or height != image.height:
|
|
325
|
+
issues.append(Issue("error", "canvas_size_mismatch", f"image is {image.width}x{image.height}, contract canvas is {width}x{height}", f"{path_prefix}.image"))
|
|
326
|
+
bbox_global = image.alpha_bbox(1, rect)
|
|
327
|
+
alpha_pixels = image.alpha_count(1, rect)
|
|
328
|
+
if alpha_pixels < min_alpha:
|
|
329
|
+
issues.append(Issue("error", "too_few_alpha_pixels", f"frame has {alpha_pixels} alpha pixels, minimum is {min_alpha}", path_prefix))
|
|
330
|
+
if not bbox_global:
|
|
331
|
+
issues.append(Issue("error", "empty_alpha_bbox", "frame has no visible pixels", f"{path_prefix}.alpha_bbox"))
|
|
332
|
+
continue
|
|
333
|
+
bbox = {"x": bbox_global["x"] - rect["x"], "y": bbox_global["y"] - rect["y"], "width": bbox_global["width"], "height": bbox_global["height"]}
|
|
334
|
+
declared_bbox = frame.get("alpha_bbox")
|
|
335
|
+
if isinstance(declared_bbox, dict) and bbox != _normalise_rect(declared_bbox, rect["width"], rect["height"]):
|
|
336
|
+
issues.append(Issue("error", "alpha_bbox_mismatch", f"measured {bbox} does not match declared {declared_bbox}", f"{path_prefix}.alpha_bbox"))
|
|
337
|
+
if not invariants.get("allow_external_opaque_pixels", False):
|
|
338
|
+
outside = image.opaque_outside(rect)
|
|
339
|
+
if outside:
|
|
340
|
+
issues.append(Issue("error", "frame_contamination", f"{outside} opaque pixels exist outside the declared frame rect", f"{path_prefix}.rect"))
|
|
341
|
+
actual_hash = hashlib.sha256(image_path.read_bytes()).hexdigest()
|
|
342
|
+
if frame.get("sha256") != actual_hash:
|
|
343
|
+
issues.append(Issue("error", "sha256_mismatch", "declared frame hash does not match file", f"{path_prefix}.sha256"))
|
|
344
|
+
pivot = frame.get("pivot") if isinstance(frame.get("pivot"), dict) else {}
|
|
345
|
+
px, py = float(pivot.get("x", 0)), float(pivot.get("y", 0))
|
|
346
|
+
if pivot.get("space") == "normalized":
|
|
347
|
+
px, py = px * width, py * height
|
|
348
|
+
footline = float(frame.get("footline_px", 0))
|
|
349
|
+
if base_pivot is None:
|
|
350
|
+
base_pivot, base_footline, base_bbox = (px, py), footline, bbox
|
|
351
|
+
else:
|
|
352
|
+
if not (_close(px, base_pivot[0], pivot_tolerance) and _close(py, base_pivot[1], pivot_tolerance)):
|
|
353
|
+
issues.append(Issue("error", "pivot_drift", f"pivot drift exceeds {pivot_tolerance}px", path_prefix))
|
|
354
|
+
if not _close(footline, base_footline, footline_tolerance):
|
|
355
|
+
issues.append(Issue("error", "footline_drift", f"footline drift exceeds {footline_tolerance}px", path_prefix))
|
|
356
|
+
if abs(bbox["width"] - base_bbox["width"]) > bbox_tolerance or abs(bbox["height"] - base_bbox["height"]) > bbox_tolerance:
|
|
357
|
+
issues.append(Issue("warning", "bbox_drift", f"bbox size drift exceeds {bbox_tolerance}px", path_prefix))
|
|
358
|
+
safe_rect = frame.get("safe_rect")
|
|
359
|
+
if isinstance(safe_rect, dict) and not _rect_inside(_normalise_rect(safe_rect, rect["width"], rect["height"]), rect["width"], rect["height"]):
|
|
360
|
+
issues.append(Issue("error", "safe_rect_out_of_bounds", "safe rect must fit inside frame rect", f"{path_prefix}.safe_rect"))
|
|
361
|
+
measurements.append({"frame_id": frame.get("frame_id"), "image": str(image_path), "width": image.width, "height": image.height, "alpha_pixels": alpha_pixels, "alpha_bbox": bbox, "sha256": actual_hash})
|
|
362
|
+
return _result("frame_geometry", issues, {"frame_count": len(measurements), "canvas": {"width": width, "height": height}, "measurements": measurements})
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def validate_atlas(document: dict[str, Any], root: Path) -> dict[str, Any]:
|
|
366
|
+
issues = _required(document, ("schema_version", "image", "allow_rotation", "padding_px", "extrude_px", "regions"))
|
|
367
|
+
if document.get("schema_version") != "0.1":
|
|
368
|
+
issues.append(Issue("error", "schema_version", "atlas schema_version must be 0.1", "schema_version"))
|
|
369
|
+
if document.get("allow_rotation") is not False:
|
|
370
|
+
issues.append(Issue("error", "rotation_not_allowed", "atlas rotation must be explicitly false", "allow_rotation"))
|
|
371
|
+
image_path = _resolve(root, str(document.get("image", "")))
|
|
372
|
+
try:
|
|
373
|
+
image = PNGImage(image_path)
|
|
374
|
+
except (OSError, PNGError, ValueError) as exc:
|
|
375
|
+
return _result("atlas", issues + [Issue("error", "image_unreadable", str(exc), "image")], {})
|
|
376
|
+
regions = document.get("regions") if isinstance(document.get("regions"), list) else []
|
|
377
|
+
rects: list[tuple[str, dict[str, int]]] = []
|
|
378
|
+
for index, region in enumerate(regions):
|
|
379
|
+
if not isinstance(region, dict):
|
|
380
|
+
issues.append(Issue("error", "invalid_region", "region must be an object", f"regions[{index}]"))
|
|
381
|
+
continue
|
|
382
|
+
rect = _normalise_rect(region.get("rect"), image.width, image.height)
|
|
383
|
+
region_id = str(region.get("region_id", index))
|
|
384
|
+
if not _rect_inside(rect, image.width, image.height):
|
|
385
|
+
issues.append(Issue("error", "region_out_of_bounds", "atlas region is outside the image", f"regions[{index}].rect"))
|
|
386
|
+
for prior_id, prior_rect in rects:
|
|
387
|
+
if _rect_overlap(rect, prior_rect):
|
|
388
|
+
issues.append(Issue("error", "region_overlap", f"region {region_id} overlaps {prior_id}", f"regions[{index}].rect"))
|
|
389
|
+
rects.append((region_id, rect))
|
|
390
|
+
outside = 0
|
|
391
|
+
if document.get("require_transparent_outside_regions", False) and rects:
|
|
392
|
+
pixels = image.rgba()
|
|
393
|
+
outside = sum(
|
|
394
|
+
1
|
|
395
|
+
for y in range(image.height)
|
|
396
|
+
for x in range(image.width)
|
|
397
|
+
if pixels[y * image.width + x][3] > 0
|
|
398
|
+
and not any(r["x"] <= x < r["x"] + r["width"] and r["y"] <= y < r["y"] + r["height"] for _, r in rects)
|
|
399
|
+
)
|
|
400
|
+
if outside:
|
|
401
|
+
issues.append(Issue("error", "atlas_contamination", f"{outside} opaque pixels exist outside atlas regions", "regions"))
|
|
402
|
+
return _result("atlas", issues, {"image": str(image_path), "width": image.width, "height": image.height, "region_count": len(rects), "opaque_pixels_outside_regions": outside})
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _contains(outer: dict[str, float], inner: dict[str, float]) -> bool:
|
|
406
|
+
return inner["x"] >= outer["x"] and inner["y"] >= outer["y"] and inner["x"] + inner["width"] <= outer["x"] + outer["width"] and inner["y"] + inner["height"] <= outer["y"] + outer["height"]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def validate_layered_map(document: dict[str, Any], root: Path, strict: bool = False) -> dict[str, Any]:
|
|
410
|
+
issues = _required(document, ("schema_version", "map_id", "coordinate_system", "world_bounds", "camera", "layers"))
|
|
411
|
+
if document.get("schema_version") != "0.1":
|
|
412
|
+
issues.append(Issue("error", "schema_version", "layered map schema_version must be 0.1", "schema_version"))
|
|
413
|
+
world = document.get("world_bounds") if isinstance(document.get("world_bounds"), dict) else {}
|
|
414
|
+
camera = document.get("camera") if isinstance(document.get("camera"), dict) else {}
|
|
415
|
+
safe = camera.get("safe_bounds") if isinstance(camera.get("safe_bounds"), dict) else {}
|
|
416
|
+
if world and safe and not _contains({k: float(world.get(k, 0)) for k in ("x", "y", "width", "height")}, {k: float(safe.get(k, 0)) for k in ("x", "y", "width", "height")}):
|
|
417
|
+
issues.append(Issue("error", "camera_safe_bounds_outside_world", "camera safe bounds must fit inside map world bounds", "camera.safe_bounds"))
|
|
418
|
+
layers = document.get("layers") if isinstance(document.get("layers"), list) else []
|
|
419
|
+
ids: set[str] = set()
|
|
420
|
+
z_values: set[int] = set()
|
|
421
|
+
ordered: list[tuple[int, float, str]] = []
|
|
422
|
+
layer_metrics: list[dict[str, Any]] = []
|
|
423
|
+
seam_tolerance = float(camera.get("seam_tolerance", 0.08) or 0.08)
|
|
424
|
+
for index, layer in enumerate(layers):
|
|
425
|
+
path_prefix = f"layers[{index}]"
|
|
426
|
+
if not isinstance(layer, dict):
|
|
427
|
+
issues.append(Issue("error", "invalid_layer", "layer must be an object", path_prefix))
|
|
428
|
+
continue
|
|
429
|
+
layer_id = str(layer.get("layer_id", ""))
|
|
430
|
+
z_index = int(layer.get("z_index", 0))
|
|
431
|
+
if layer_id in ids:
|
|
432
|
+
issues.append(Issue("error", "duplicate_layer_id", f"duplicate layer_id: {layer_id}", f"{path_prefix}.layer_id"))
|
|
433
|
+
if z_index in z_values:
|
|
434
|
+
issues.append(Issue("error", "duplicate_z_index", f"duplicate z_index: {z_index}", f"{path_prefix}.z_index"))
|
|
435
|
+
ids.add(layer_id)
|
|
436
|
+
z_values.add(z_index)
|
|
437
|
+
parallax = layer.get("parallax") if isinstance(layer.get("parallax"), dict) else {}
|
|
438
|
+
px, py = float(parallax.get("x", -1)), float(parallax.get("y", -1))
|
|
439
|
+
if px < 0 or py < 0:
|
|
440
|
+
issues.append(Issue("error", "invalid_parallax", "parallax factors must be non-negative", f"{path_prefix}.parallax"))
|
|
441
|
+
ordered.append((z_index, px, layer_id))
|
|
442
|
+
layer_world = layer.get("world_bounds") if isinstance(layer.get("world_bounds"), dict) else {}
|
|
443
|
+
layer_safe = layer.get("camera_safe_bounds") if isinstance(layer.get("camera_safe_bounds"), dict) else {}
|
|
444
|
+
if layer_world and layer_safe and not _contains({k: float(layer_world.get(k, 0)) for k in ("x", "y", "width", "height")}, {k: float(layer_safe.get(k, 0)) for k in ("x", "y", "width", "height")}):
|
|
445
|
+
issues.append(Issue("error", "layer_safe_bounds_outside_world", "layer camera safe bounds must fit inside layer world bounds", f"{path_prefix}.camera_safe_bounds"))
|
|
446
|
+
image_path = _resolve(root, str(layer.get("image", "")))
|
|
447
|
+
metric: dict[str, Any] = {"layer_id": layer_id, "z_index": z_index, "parallax": {"x": px, "y": py}, "image": str(image_path)}
|
|
448
|
+
try:
|
|
449
|
+
image = PNGImage(image_path)
|
|
450
|
+
metric.update({"width": image.width, "height": image.height})
|
|
451
|
+
tileable = layer.get("tileable") if isinstance(layer.get("tileable"), dict) else {}
|
|
452
|
+
if tileable.get("x") and image.edge_difference("x") > seam_tolerance:
|
|
453
|
+
issues.append(Issue("error", "horizontal_seam", f"tileable x edge difference exceeds {seam_tolerance}", f"{path_prefix}.image"))
|
|
454
|
+
if tileable.get("y") and image.edge_difference("y") > seam_tolerance:
|
|
455
|
+
issues.append(Issue("error", "vertical_seam", f"tileable y edge difference exceeds {seam_tolerance}", f"{path_prefix}.image"))
|
|
456
|
+
metric["edge_difference"] = {"x": image.edge_difference("x"), "y": image.edge_difference("y")}
|
|
457
|
+
except (OSError, PNGError, ValueError) as exc:
|
|
458
|
+
issues.append(Issue("error", "image_unreadable", str(exc), f"{path_prefix}.image"))
|
|
459
|
+
layer_metrics.append(metric)
|
|
460
|
+
ordered.sort()
|
|
461
|
+
previous_parallax: float | None = None
|
|
462
|
+
for z_index, parallax, layer_id in ordered:
|
|
463
|
+
if previous_parallax is not None and parallax < previous_parallax:
|
|
464
|
+
issue = Issue("error" if strict else "warning", "parallax_order_drift", f"parallax decreases at layer {layer_id}; verify intentional depth ordering", f"layers[{layer_id}].parallax")
|
|
465
|
+
issues.append(issue)
|
|
466
|
+
previous_parallax = parallax
|
|
467
|
+
return _result("layered_map", issues, {"map_id": document.get("map_id"), "layer_count": len(layer_metrics), "layers": layer_metrics})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _result(kind: str, issues: list[Issue], metrics: dict[str, Any]) -> dict[str, Any]:
|
|
471
|
+
errors = [asdict(issue) for issue in issues if issue.severity == "error"]
|
|
472
|
+
warnings = [asdict(issue) for issue in issues if issue.severity == "warning"]
|
|
473
|
+
return {"contract": kind, "status": "blocked" if errors else "pass", "ready": not errors, "errors": errors, "warnings": warnings, "metrics": metrics}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def run(kind: str, document: dict[str, Any], root: Path, strict: bool) -> dict[str, Any]:
|
|
477
|
+
if kind == "identity":
|
|
478
|
+
return validate_identity(document)
|
|
479
|
+
if kind == "action-set":
|
|
480
|
+
return validate_action_set(document)
|
|
481
|
+
if kind == "frame-geometry":
|
|
482
|
+
return validate_frame_geometry(document, root)
|
|
483
|
+
if kind == "atlas":
|
|
484
|
+
return validate_atlas(document, root)
|
|
485
|
+
if kind == "layered-map":
|
|
486
|
+
return validate_layered_map(document, root, strict)
|
|
487
|
+
return _result("unknown", [Issue("error", "unknown_kind", f"unsupported consistency kind: {kind}", "kind")], {})
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def main(argv: list[str] | None = None) -> int:
|
|
491
|
+
parser = argparse.ArgumentParser(description="Validate deterministic MotionLoom asset consistency contracts")
|
|
492
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
493
|
+
for command in ("validate", "analyze", "report"):
|
|
494
|
+
command_parser = sub.add_parser(command)
|
|
495
|
+
command_parser.add_argument("--kind", choices=("identity", "action-set", "frame-geometry", "atlas", "layered-map"), required=True)
|
|
496
|
+
command_parser.add_argument("--input", required=True, type=Path)
|
|
497
|
+
command_parser.add_argument("--root", type=Path, default=Path("."))
|
|
498
|
+
command_parser.add_argument("--output", type=Path)
|
|
499
|
+
command_parser.add_argument("--strict", action="store_true")
|
|
500
|
+
command_parser.add_argument("--json", action="store_true")
|
|
501
|
+
args = parser.parse_args(argv)
|
|
502
|
+
document, load_issues = _load_document(args.input)
|
|
503
|
+
result = _result("document", load_issues, {}) if document is None else run(args.kind, document, args.root.resolve(), args.strict)
|
|
504
|
+
if args.output:
|
|
505
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
506
|
+
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
507
|
+
if args.json or args.command != "report":
|
|
508
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
509
|
+
else:
|
|
510
|
+
print(f"{result['contract']}: {result['status']} ({len(result['errors'])} errors, {len(result['warnings'])} warnings)")
|
|
511
|
+
for issue in result["errors"] + result["warnings"]:
|
|
512
|
+
print(f"- {issue['severity']}: {issue['code']}: {issue['message']}")
|
|
513
|
+
return 0 if result.get("ready") else 1
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
raise SystemExit(main())
|
|
@@ -25,6 +25,7 @@ AUTHORITIES = {
|
|
|
25
25
|
"ai_assisted",
|
|
26
26
|
"ai_assisted_human_reviewed",
|
|
27
27
|
"artist_authored",
|
|
28
|
+
"code_authored",
|
|
28
29
|
"unknown",
|
|
29
30
|
}
|
|
30
31
|
READINESS = {
|
|
@@ -208,6 +209,8 @@ def validate_document(data: dict[str, Any]) -> list[str]:
|
|
|
208
209
|
errors.append("unknown authority is always blocked")
|
|
209
210
|
if authority == "ai_generated" and readiness != "runtime_ready":
|
|
210
211
|
errors.append("ai_generated assets may be runtime_ready only; they are never production eligible")
|
|
212
|
+
if authority == "code_authored" and readiness not in {"runtime_ready", "review_required"}:
|
|
213
|
+
errors.append("code_authored runtime scenes may be runtime_ready or review_required only; production eligibility remains a separate human-governed release lane")
|
|
211
214
|
if authority == "ai_assisted_human_reviewed" and readiness not in {"review_required", "production_eligible"}:
|
|
212
215
|
errors.append("ai_assisted_human_reviewed assets must remain review_required or pass a later production gate")
|
|
213
216
|
if authority == "ai_assisted_human_reviewed":
|
|
@@ -231,7 +234,7 @@ def validate_document(data: dict[str, Any]) -> list[str]:
|
|
|
231
234
|
errors.append("full_gate.checked_at must be an ISO-8601 timestamp")
|
|
232
235
|
if authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
|
|
233
236
|
_check_human_review(data, errors)
|
|
234
|
-
if authority in {"unknown", "ai_generated"}:
|
|
237
|
+
if authority in {"unknown", "ai_generated", "code_authored"}:
|
|
235
238
|
errors.append(f"{authority} cannot be production eligible")
|
|
236
239
|
if readiness == "production_approved":
|
|
237
240
|
approval = data.get("human_approval")
|
|
@@ -248,8 +251,8 @@ def validate_document(data: dict[str, Any]) -> list[str]:
|
|
|
248
251
|
errors.append("human_approval.approved_at must be an ISO-8601 timestamp")
|
|
249
252
|
if approval.get("user_confirmed") is not True:
|
|
250
253
|
errors.append("human_approval.user_confirmed must be true")
|
|
251
|
-
if authority in {"unknown", "ai_generated"}:
|
|
252
|
-
errors.append("unknown and
|
|
254
|
+
if authority in {"unknown", "ai_generated", "code_authored"}:
|
|
255
|
+
errors.append("unknown, ai_generated and code_authored assets can never be production_approved")
|
|
253
256
|
if "human_approval" in data and readiness != "production_approved":
|
|
254
257
|
errors.append("human_approval is only valid when readiness is production_approved")
|
|
255
258
|
return errors
|
|
@@ -292,6 +295,8 @@ def classify(data: dict[str, Any], errors: list[str] | None = None) -> dict[str,
|
|
|
292
295
|
if not errors:
|
|
293
296
|
if authority == "ai_generated":
|
|
294
297
|
effective = "runtime_ready"
|
|
298
|
+
elif authority == "code_authored":
|
|
299
|
+
effective = "review_required" if readiness == "review_required" else "runtime_ready"
|
|
295
300
|
elif authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
|
|
296
301
|
review = data.get("human_review") or {}
|
|
297
302
|
if data.get("full_gate", {}).get("status") == "pass" and review.get("decision") == "approved" and review.get("user_confirmed") is True:
|