motionloom 2.3.0 → 2.5.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 (113) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +97 -11
  3. package/ROADMAP.md +8 -5
  4. package/SKILL.md +24 -5
  5. package/agent-card.json +34 -5
  6. package/agent-surfaces.json +1 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +43 -6
  9. package/capability-registry.json +308 -0
  10. package/docs/CHECKLIST.md +16 -0
  11. package/docs/STATUS.md +2 -2
  12. package/docs/apple-distribution.md +24 -0
  13. package/docs/architecture/apple/cloudkit.md +18 -0
  14. package/docs/architecture/apple/contracts.md +9 -0
  15. package/docs/architecture/apple/implementation-status-2026-08-16.md +55 -0
  16. package/docs/architecture/apple/studio.md +35 -0
  17. package/docs/audits/data/deep-stress-latest.json +65 -65
  18. package/docs/releases/2.4.0.md +31 -0
  19. package/docs/releases/2.5.0.md +31 -0
  20. package/docs/releases/npm-publish-from-workstation.md +19 -8
  21. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  22. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  23. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  24. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  25. package/docs/research/ai-repository-benchmark-2026-08-16.md +75 -0
  26. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  27. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  28. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  29. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  30. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  31. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  32. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  33. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  34. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  35. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  36. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  37. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  38. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  39. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  40. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  41. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  42. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  43. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  44. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  45. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  46. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  47. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  48. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  49. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  50. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  51. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  52. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  53. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  54. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  55. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  56. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  57. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  58. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  59. package/package.json +58 -4
  60. package/references/browser-review-contract.md +7 -1
  61. package/references/intelligence-core.md +22 -1
  62. package/rig-adapter-registry.json +39 -0
  63. package/schemas/action-set.schema.json +42 -0
  64. package/schemas/artifact-adapter-registry.schema.json +16 -0
  65. package/schemas/asset-identity.schema.json +117 -0
  66. package/schemas/asset-provenance.schema.json +1 -1
  67. package/schemas/atlas-contract.schema.json +48 -0
  68. package/schemas/control-track.schema.json +18 -0
  69. package/schemas/export-manifest.schema.json +19 -0
  70. package/schemas/frame-geometry.schema.json +79 -0
  71. package/schemas/generation-receipt.schema.json +20 -0
  72. package/schemas/layered-map.schema.json +75 -0
  73. package/schemas/rig-adapter-registry.schema.json +14 -0
  74. package/schemas/rig-compatibility.schema.json +53 -0
  75. package/schemas/rive-package-manifest.schema.json +43 -0
  76. package/schemas/runtime-candidate.schema.json +48 -0
  77. package/schemas/scene-manifest.schema.json +18 -0
  78. package/scripts/artifact-intake.py +408 -0
  79. package/scripts/asset-consistency.py +517 -0
  80. package/scripts/asset-provenance.py +8 -3
  81. package/scripts/attestation.py +8 -0
  82. package/scripts/build-ai-pilot.py +504 -0
  83. package/scripts/evidence-verifier.py +18 -9
  84. package/scripts/intelligence.py +85 -18
  85. package/scripts/isolate-alpha-background.py +147 -0
  86. package/scripts/quality-gate.py +88 -2
  87. package/scripts/report.py +85 -0
  88. package/scripts/resolve-task-bundle.py +84 -0
  89. package/scripts/review-hook.py +26 -4
  90. package/scripts/rig-compatibility.py +251 -0
  91. package/scripts/rive-package-gate.py +245 -0
  92. package/scripts/runtime-candidate.py +301 -0
  93. package/scripts/setup.mjs +54 -26
  94. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  95. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  96. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  97. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  98. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  99. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  100. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  101. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  102. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  103. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  104. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  105. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  106. package/tests/scripts/run_tests.py +151 -0
  107. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  108. package/tests/scripts/test_alpha_isolation.py +60 -0
  109. package/tests/scripts/test_artifact_intake.py +146 -0
  110. package/tests/scripts/test_asset_consistency.py +199 -0
  111. package/tests/scripts/test_rig_compatibility.py +105 -0
  112. package/tests/scripts/test_rive_package_gate.py +101 -0
  113. 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 ai_generated assets can never be production_approved")
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:
@@ -41,6 +41,11 @@ def dsse_pae(payload_type: str, body: bytes) -> bytes:
41
41
  return b"DSSEv1 " + str(len(payload_type_bytes)).encode("ascii") + b" " + payload_type_bytes + b" " + str(len(body)).encode("ascii") + b" " + body
42
42
 
43
43
 
44
+ def is_macos_var_alias(path: Path) -> bool:
45
+ """Allow only macOS's documented lexical alias `/var` -> `/private/var`."""
46
+ return path == Path("/var") and path.is_symlink() and path.resolve() == Path("/private/var")
47
+
48
+
44
49
  def reject_symlink_components(path: Path) -> None:
45
50
  current = Path(path.anchor) if path.anchor else Path(".")
46
51
  for part in path.parts:
@@ -48,6 +53,9 @@ def reject_symlink_components(path: Path) -> None:
48
53
  continue
49
54
  current = current / part
50
55
  if current.is_symlink():
56
+ if is_macos_var_alias(current):
57
+ current = current.resolve()
58
+ continue
51
59
  raise ValueError(f"symlink path component is not allowed: {path}")
52
60
 
53
61