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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/SKILL.md +10 -3
- package/agent-card.json +62 -13
- package/agent-surfaces.json +30 -7
- package/bin/motionloom.mjs +6 -0
- package/docs/STATUS.md +2 -2
- package/docs/releases/2.6.1.md +57 -0
- package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +84 -0
- package/package.json +9 -9
- package/references/multi-frame-asset-generation.md +109 -0
- package/schemas/frame-generation-lock.schema.json +166 -0
- package/scripts/frame-generation-lock.py +328 -0
- package/scripts/frame-set-preflight.py +214 -0
- package/tests/scripts/test_asset_consistency.py +77 -4
|
@@ -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
|
|
24
|
-
spec = importlib.util.spec_from_file_location(
|
|
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
|
|
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 =
|
|
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()
|