motionloom 2.2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +123 -17
  3. package/ROADMAP.md +8 -3
  4. package/SKILL.md +34 -3
  5. package/agent-card.json +48 -5
  6. package/agent-surfaces.json +8 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +41 -4
  9. package/docs/AGENT-INTEGRATION.md +15 -2
  10. package/docs/CHECKLIST.md +21 -0
  11. package/docs/STATUS.md +2 -2
  12. package/docs/audits/data/deep-stress-latest.json +65 -65
  13. package/docs/releases/2.3.0.md +33 -0
  14. package/docs/releases/2.4.0.md +31 -0
  15. package/docs/releases/npm-publish-from-workstation.md +19 -8
  16. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  17. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  18. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  19. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  20. package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
  21. package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
  22. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  23. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  24. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  25. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  26. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  27. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  28. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  29. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  30. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  31. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  32. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  33. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  34. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  35. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  36. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  37. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  38. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  39. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  40. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  41. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  42. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  43. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  44. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  45. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  46. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  47. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  48. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  49. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  50. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  51. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  52. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  53. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  54. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  55. package/package.json +65 -4
  56. package/references/agent-interoperability.md +11 -0
  57. package/references/browser-review-contract.md +7 -1
  58. package/references/intelligence-core.md +29 -2
  59. package/rig-adapter-registry.json +39 -0
  60. package/schemas/action-set.schema.json +42 -0
  61. package/schemas/agent-surfaces.schema.json +1 -1
  62. package/schemas/artifact-adapter-registry.schema.json +16 -0
  63. package/schemas/asset-identity.schema.json +117 -0
  64. package/schemas/asset-provenance.schema.json +183 -0
  65. package/schemas/atlas-contract.schema.json +48 -0
  66. package/schemas/control-track.schema.json +18 -0
  67. package/schemas/export-manifest.schema.json +19 -0
  68. package/schemas/frame-geometry.schema.json +79 -0
  69. package/schemas/generation-receipt.schema.json +20 -0
  70. package/schemas/layered-map.schema.json +75 -0
  71. package/schemas/rig-adapter-registry.schema.json +14 -0
  72. package/schemas/rig-compatibility.schema.json +53 -0
  73. package/schemas/rive-package-manifest.schema.json +43 -0
  74. package/schemas/runtime-candidate.schema.json +48 -0
  75. package/schemas/scene-manifest.schema.json +19 -0
  76. package/scripts/artifact-intake.py +408 -0
  77. package/scripts/asset-consistency.py +517 -0
  78. package/scripts/asset-provenance.py +395 -0
  79. package/scripts/build-ai-pilot.py +504 -0
  80. package/scripts/docs-audit.py +14 -2
  81. package/scripts/isolate-alpha-background.py +147 -0
  82. package/scripts/pr.py +2 -0
  83. package/scripts/quality-gate.py +127 -3
  84. package/scripts/report.py +135 -0
  85. package/scripts/resolve-task-bundle.py +84 -0
  86. package/scripts/review-hook.py +26 -4
  87. package/scripts/rig-compatibility.py +251 -0
  88. package/scripts/rive-package-gate.py +245 -0
  89. package/scripts/runtime-candidate.py +301 -0
  90. package/scripts/setup.mjs +500 -0
  91. package/scripts/skill-doctor.py +2 -1
  92. package/src/output/browser-review-smoke/asset-provenance.json +77 -0
  93. package/src/output/browser-review-smoke/manifest.json +1 -0
  94. package/src/output/browser-review-smoke/visual-truth.json +3 -3
  95. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  96. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  97. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  98. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  99. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  100. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  101. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  102. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  103. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  104. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  105. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  106. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  107. package/tests/scripts/run_tests.py +131 -0
  108. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  109. package/tests/scripts/test_alpha_isolation.py +60 -0
  110. package/tests/scripts/test_artifact_intake.py +146 -0
  111. package/tests/scripts/test_asset_consistency.py +199 -0
  112. package/tests/scripts/test_rig_compatibility.py +105 -0
  113. package/tests/scripts/test_rive_package_gate.py +101 -0
  114. 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())