motionloom 2.6.0 → 2.6.1

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.
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed preflight for Agent-generated multi-frame source assets.
3
+
4
+ This wrapper reuses MotionLoom's deterministic frame-geometry measurements, then
5
+ adds generation-time rules that are intentionally stricter than a generic asset
6
+ inspection: source frames must be isolated canvases, scale drift is blocking,
7
+ and the measured alpha bounds must preserve the declared transparent guard band.
8
+
9
+ It never grants provenance authority, artistic approval or production approval.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.util
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+ ASSET_CONSISTENCY = ROOT / "scripts" / "asset-consistency.py"
24
+
25
+
26
+ def load_asset_consistency():
27
+ spec = importlib.util.spec_from_file_location("motionloom_asset_consistency", ASSET_CONSISTENCY)
28
+ if spec is None or spec.loader is None:
29
+ raise RuntimeError("cannot load asset-consistency.py")
30
+ module = importlib.util.module_from_spec(spec)
31
+ sys.modules[spec.name] = module
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ AC = load_asset_consistency()
37
+
38
+
39
+ def _error(code: str, message: str, path: str = "") -> dict[str, str]:
40
+ return {"severity": "error", "code": code, "message": message, "path": path}
41
+
42
+
43
+ def _normalise_rect(value: dict[str, Any]) -> dict[str, int]:
44
+ return {key: int(value[key]) for key in ("x", "y", "width", "height")}
45
+
46
+
47
+ def _contains(outer: dict[str, int], inner: dict[str, int]) -> bool:
48
+ return (
49
+ inner["x"] >= outer["x"]
50
+ and inner["y"] >= outer["y"]
51
+ and inner["x"] + inner["width"] <= outer["x"] + outer["width"]
52
+ and inner["y"] + inner["height"] <= outer["y"] + outer["height"]
53
+ )
54
+
55
+
56
+ def _shrink(rect: dict[str, int], margin: int) -> dict[str, int] | None:
57
+ width = rect["width"] - 2 * margin
58
+ height = rect["height"] - 2 * margin
59
+ if width <= 0 or height <= 0:
60
+ return None
61
+ return {
62
+ "x": rect["x"] + margin,
63
+ "y": rect["y"] + margin,
64
+ "width": width,
65
+ "height": height,
66
+ }
67
+
68
+
69
+ def validate(document: dict[str, Any], root: Path, allow_shared_source: bool = False) -> dict[str, Any]:
70
+ base = AC.validate_frame_geometry(document, root)
71
+ errors = list(base.get("errors", []))
72
+ warnings = list(base.get("warnings", []))
73
+ frames = document.get("frames") if isinstance(document.get("frames"), list) else []
74
+ canvas = document.get("canvas") if isinstance(document.get("canvas"), dict) else {}
75
+ canvas_width = int(canvas.get("width", 0) or 0)
76
+ canvas_height = int(canvas.get("height", 0) or 0)
77
+
78
+ measurements_by_id = {
79
+ str(item.get("frame_id")): item
80
+ for item in base.get("metrics", {}).get("measurements", [])
81
+ if isinstance(item, dict)
82
+ }
83
+
84
+ seen_images: dict[str, int] = {}
85
+ for index, frame in enumerate(frames):
86
+ if not isinstance(frame, dict):
87
+ continue
88
+ prefix = f"frames[{index}]"
89
+ image_value = str(frame.get("image", ""))
90
+ if image_value:
91
+ if image_value in seen_images and not allow_shared_source:
92
+ errors.append(
93
+ _error(
94
+ "shared_source_image",
95
+ f"generated source frame reuses image {image_value!r}; use one isolated image per source frame",
96
+ f"{prefix}.image",
97
+ )
98
+ )
99
+ seen_images[image_value] = index
100
+
101
+ rect = frame.get("rect") if isinstance(frame.get("rect"), dict) else None
102
+ if rect and canvas_width > 0 and canvas_height > 0 and not allow_shared_source:
103
+ expected = {"x": 0, "y": 0, "width": canvas_width, "height": canvas_height}
104
+ try:
105
+ actual = _normalise_rect(rect)
106
+ except (KeyError, TypeError, ValueError):
107
+ actual = {}
108
+ if actual != expected:
109
+ errors.append(
110
+ _error(
111
+ "non_isolated_source",
112
+ f"generated source frame rect must own the full {canvas_width}x{canvas_height} canvas before atlas packing",
113
+ f"{prefix}.rect",
114
+ )
115
+ )
116
+
117
+ frame_id = str(frame.get("frame_id", ""))
118
+ measurement = measurements_by_id.get(frame_id)
119
+ safe_rect_value = frame.get("safe_rect") if isinstance(frame.get("safe_rect"), dict) else None
120
+ if measurement and safe_rect_value:
121
+ try:
122
+ safe_rect = _normalise_rect(safe_rect_value)
123
+ bbox = _normalise_rect(measurement["alpha_bbox"])
124
+ margin = int(frame.get("bleed_margin_px", 0) or 0)
125
+ guarded = _shrink(safe_rect, margin)
126
+ except (KeyError, TypeError, ValueError):
127
+ guarded = None
128
+ bbox = None
129
+ if guarded is None:
130
+ errors.append(
131
+ _error(
132
+ "invalid_guard_band",
133
+ "safe_rect is too small for the declared bleed_margin_px",
134
+ f"{prefix}.bleed_margin_px",
135
+ )
136
+ )
137
+ elif bbox is not None and not _contains(guarded, bbox):
138
+ errors.append(
139
+ _error(
140
+ "guard_band_violation",
141
+ f"measured alpha bbox {bbox} leaves less than {frame.get('bleed_margin_px', 0)}px inside safe_rect {safe_rect}",
142
+ f"{prefix}.safe_rect",
143
+ )
144
+ )
145
+
146
+ remaining_warnings: list[dict[str, Any]] = []
147
+ for item in warnings:
148
+ if isinstance(item, dict) and item.get("code") == "bbox_drift":
149
+ promoted = dict(item)
150
+ promoted["severity"] = "error"
151
+ promoted["message"] = f"{promoted.get('message', 'bbox drift exceeds tolerance')}; generated frame scale drift blocks preflight"
152
+ errors.append(promoted)
153
+ else:
154
+ remaining_warnings.append(item)
155
+
156
+ return {
157
+ "contract": "generated_frame_set_preflight",
158
+ "ready": not errors,
159
+ "errors": errors,
160
+ "warnings": remaining_warnings,
161
+ "metrics": {
162
+ **base.get("metrics", {}),
163
+ "isolated_source_required": not allow_shared_source,
164
+ "unique_source_images": len(seen_images),
165
+ },
166
+ "approval": False,
167
+ }
168
+
169
+
170
+ def main(argv: list[str] | None = None) -> int:
171
+ parser = argparse.ArgumentParser(description=__doc__)
172
+ parser.add_argument("--input", required=True, help="frame-geometry contract JSON")
173
+ parser.add_argument("--root", default=".", help="root used to resolve frame paths")
174
+ parser.add_argument("--allow-shared-source", action="store_true", help="for imported/shared canvases only; not recommended for generated source frames")
175
+ parser.add_argument("--json", action="store_true", dest="as_json")
176
+ args = parser.parse_args(argv)
177
+
178
+ try:
179
+ document = json.loads(Path(args.input).read_text(encoding="utf-8"))
180
+ except (OSError, json.JSONDecodeError) as exc:
181
+ result = {
182
+ "contract": "generated_frame_set_preflight",
183
+ "ready": False,
184
+ "errors": [_error("invalid_input", str(exc), args.input)],
185
+ "warnings": [],
186
+ "metrics": {},
187
+ "approval": False,
188
+ }
189
+ else:
190
+ if not isinstance(document, dict):
191
+ result = {
192
+ "contract": "generated_frame_set_preflight",
193
+ "ready": False,
194
+ "errors": [_error("invalid_input", "contract root must be an object", args.input)],
195
+ "warnings": [],
196
+ "metrics": {},
197
+ "approval": False,
198
+ }
199
+ else:
200
+ result = validate(document, Path(args.root).resolve(), args.allow_shared_source)
201
+
202
+ if args.as_json:
203
+ print(json.dumps(result, indent=2, ensure_ascii=False))
204
+ else:
205
+ print(f"generated frame-set preflight: {'PASS' if result['ready'] else 'FAIL'}")
206
+ for item in result.get("errors", []):
207
+ print(f"ERROR {item.get('code')}: {item.get('message')}")
208
+ for item in result.get("warnings", []):
209
+ print(f"WARN {item.get('code')}: {item.get('message')}")
210
+ return 0 if result["ready"] else 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())
@@ -18,19 +18,21 @@ from pathlib import Path
18
18
  ROOT = Path(__file__).resolve().parents[2]
19
19
  FIXTURE_ROOT = ROOT / "examples/agent-consumer/asset-consistency"
20
20
  ANALYZER_PATH = ROOT / "scripts/asset-consistency.py"
21
+ PREFLIGHT_PATH = ROOT / "scripts/frame-set-preflight.py"
21
22
 
22
23
 
23
- def load_analyzer():
24
- spec = importlib.util.spec_from_file_location("asset_consistency", ANALYZER_PATH)
24
+ def load_module(path: Path, name: str):
25
+ spec = importlib.util.spec_from_file_location(name, path)
25
26
  if spec is None or spec.loader is None:
26
- raise RuntimeError("cannot load asset consistency analyzer")
27
+ raise RuntimeError(f"cannot load {path.name}")
27
28
  module = importlib.util.module_from_spec(spec)
28
29
  sys.modules[spec.name] = module
29
30
  spec.loader.exec_module(module)
30
31
  return module
31
32
 
32
33
 
33
- AC = load_analyzer()
34
+ AC = load_module(ANALYZER_PATH, "asset_consistency")
35
+ PREFLIGHT = load_module(PREFLIGHT_PATH, "frame_set_preflight")
34
36
 
35
37
 
36
38
  def read_json(name: str) -> dict:
@@ -112,6 +114,65 @@ def test_frame_contamination_and_pivot_drift() -> None:
112
114
  check(not result["ready"] and any(item["code"] == "pivot_drift" for item in result["errors"]), "pivot drift must block")
113
115
 
114
116
 
117
+ def test_generated_frame_set_preflight() -> None:
118
+ geometry = read_json("hero-walk-frame-geometry.json")
119
+ result = PREFLIGHT.validate(geometry, FIXTURE_ROOT)
120
+ check(result["ready"], f"isolated generated frame fixture must pass preflight: {result}")
121
+ check(result.get("approval") is False, "preflight must never emit approval")
122
+
123
+ shared = read_json("hero-walk-frame-geometry.json")
124
+ shared["frames"][1]["image"] = shared["frames"][0]["image"]
125
+ shared["frames"][1]["sha256"] = shared["frames"][0]["sha256"]
126
+ result = PREFLIGHT.validate(shared, FIXTURE_ROOT)
127
+ check(
128
+ not result["ready"] and any(item["code"] == "shared_source_image" for item in result["errors"]),
129
+ "generated multi-frame source must reject a shared pose-sheet image",
130
+ )
131
+
132
+ guard = read_json("hero-walk-frame-geometry.json")
133
+ guard["frames"][0]["safe_rect"] = {"x": 2, "y": 1, "width": 4, "height": 5}
134
+ guard["frames"][0]["bleed_margin_px"] = 1
135
+ result = PREFLIGHT.validate(guard, FIXTURE_ROOT)
136
+ check(
137
+ not result["ready"] and any(item["code"] == "guard_band_violation" for item in result["errors"]),
138
+ "generated frame alpha must preserve the declared transparent guard band",
139
+ )
140
+
141
+ with tempfile.TemporaryDirectory() as td:
142
+ root = Path(td)
143
+ geometry = read_json("hero-walk-frame-geometry.json")
144
+ geometry["frames"] = [copy.deepcopy(geometry["frames"][0]), copy.deepcopy(geometry["frames"][1])]
145
+ geometry["invariants"]["bbox_drift_tolerance_px"] = 0
146
+ for frame in geometry["frames"]:
147
+ frame["rect"] = {"x": 0, "y": 0, "width": 8, "height": 8}
148
+ frame["safe_rect"] = {"x": 0, "y": 0, "width": 8, "height": 8}
149
+ frame["bleed_margin_px"] = 0
150
+
151
+ first_pixels = solid(8, 8, (0, 0, 0, 0))
152
+ for y in range(1, 6):
153
+ for x in range(2, 6):
154
+ first_pixels[y * 8 + x] = (120, 160, 220, 255)
155
+ second_pixels = solid(8, 8, (0, 0, 0, 0))
156
+ for y in range(1, 6):
157
+ for x in range(1, 7):
158
+ second_pixels[y * 8 + x] = (120, 160, 220, 255)
159
+ write_rgba_png(root / "frame-00.png", 8, 8, first_pixels)
160
+ write_rgba_png(root / "frame-01.png", 8, 8, second_pixels)
161
+
162
+ geometry["frames"][0]["image"] = "frame-00.png"
163
+ geometry["frames"][0]["alpha_bbox"] = {"x": 2, "y": 1, "width": 4, "height": 5}
164
+ geometry["frames"][0]["sha256"] = hashlib.sha256((root / "frame-00.png").read_bytes()).hexdigest()
165
+ geometry["frames"][1]["image"] = "frame-01.png"
166
+ geometry["frames"][1]["alpha_bbox"] = {"x": 1, "y": 1, "width": 6, "height": 5}
167
+ geometry["frames"][1]["sha256"] = hashlib.sha256((root / "frame-01.png").read_bytes()).hexdigest()
168
+
169
+ result = PREFLIGHT.validate(geometry, root)
170
+ check(
171
+ not result["ready"] and any(item["code"] == "bbox_drift" for item in result["errors"]),
172
+ "generated frame apparent-size drift beyond tolerance must block instead of warning",
173
+ )
174
+
175
+
115
176
  def test_atlas_overlap_and_contamination() -> None:
116
177
  atlas = read_json("hero-atlas-contract.json")
117
178
  atlas["regions"][1]["rect"]["x"] = 4
@@ -179,11 +240,23 @@ def test_missing_file_and_cli_surface() -> None:
179
240
  data = json.loads(cli.stdout)
180
241
  check(cli.returncode == 0 and data.get("ready") is True and data.get("contract") == "layered_map", "CLI must expose the consistency validator")
181
242
 
243
+ preflight_cli = subprocess.run([
244
+ sys.executable, str(PREFLIGHT_PATH),
245
+ "--input", str(FIXTURE_ROOT / "hero-walk-frame-geometry.json"),
246
+ "--root", str(FIXTURE_ROOT), "--json",
247
+ ], cwd=ROOT, capture_output=True, text=True)
248
+ preflight_data = json.loads(preflight_cli.stdout)
249
+ check(
250
+ preflight_cli.returncode == 0 and preflight_data.get("ready") is True and preflight_data.get("approval") is False,
251
+ "generated frame-set preflight CLI must pass isolated fixture without granting approval",
252
+ )
253
+
182
254
 
183
255
  def main() -> int:
184
256
  test_pass_fixtures()
185
257
  test_identity_and_action_fail_closed()
186
258
  test_frame_contamination_and_pivot_drift()
259
+ test_generated_frame_set_preflight()
187
260
  test_atlas_overlap_and_contamination()
188
261
  test_layered_map_order_seam_and_bounds()
189
262
  test_missing_file_and_cli_surface()