motionloom 2.5.1 → 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 +4 -0
- package/CHANGELOG.md +58 -1
- package/README.md +18 -7
- package/SECURITY.md +6 -7
- package/SKILL.md +14 -5
- package/agent-card.json +65 -16
- package/agent-surfaces.json +30 -7
- package/bin/motionloom.mjs +6 -0
- package/dev-lab/public/action-library.js +899 -0
- package/dev-lab/public/devlab.js +648 -119
- package/dev-lab/public/index.html +151 -18
- package/dev-lab/public/runtime-bridge.js +109 -0
- package/docs/CHECKLIST.md +15 -2
- package/docs/DEV-LAB-RUNTIME.md +160 -0
- package/docs/DEV-LAB-STATE-TRANSITIONS.md +121 -0
- package/docs/STATUS.md +3 -3
- package/docs/releases/2.6.0.md +38 -0
- 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 +11 -9
- package/references/multi-frame-asset-generation.md +109 -0
- package/schemas/browser-review-candidate.schema.json +37 -0
- package/schemas/devlab-runtime.schema.json +140 -0
- package/schemas/devlab-state-machine.schema.json +103 -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/scripts/review-hook.py +255 -19
- package/tests/scripts/test_asset_consistency.py +77 -4
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://motionloom.dev/schemas/devlab-state-machine.schema.json",
|
|
4
|
+
"title": "MotionLoom Dev Lab state/transition review contract",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schema_version", "initial_state", "states", "transitions", "review_policy"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "const": "1.0" },
|
|
10
|
+
"initial_state": { "$ref": "#/$defs/id" },
|
|
11
|
+
"states": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"minItems": 1,
|
|
14
|
+
"maxItems": 256,
|
|
15
|
+
"items": { "$ref": "#/$defs/state" }
|
|
16
|
+
},
|
|
17
|
+
"transitions": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"minItems": 1,
|
|
20
|
+
"maxItems": 512,
|
|
21
|
+
"items": { "$ref": "#/$defs/transition" }
|
|
22
|
+
},
|
|
23
|
+
"sequences": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"maxItems": 64,
|
|
26
|
+
"items": { "$ref": "#/$defs/sequence" }
|
|
27
|
+
},
|
|
28
|
+
"review_policy": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"required": ["require_all_transitions", "require_all_sequences"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"require_all_transitions": { "type": "boolean" },
|
|
34
|
+
"require_all_sequences": { "type": "boolean" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"$defs": {
|
|
39
|
+
"id": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
|
|
42
|
+
},
|
|
43
|
+
"state": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"additionalProperties": false,
|
|
46
|
+
"required": ["id", "animation"],
|
|
47
|
+
"properties": {
|
|
48
|
+
"id": { "$ref": "#/$defs/id" },
|
|
49
|
+
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
50
|
+
"animation": { "$ref": "#/$defs/id" },
|
|
51
|
+
"description": { "type": "string", "maxLength": 500 }
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"transition": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"required": ["id", "from", "to", "mode"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"id": { "$ref": "#/$defs/id" },
|
|
60
|
+
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
61
|
+
"from": {
|
|
62
|
+
"oneOf": [
|
|
63
|
+
{ "$ref": "#/$defs/id" },
|
|
64
|
+
{ "const": "*" }
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"to": { "$ref": "#/$defs/id" },
|
|
68
|
+
"trigger": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
69
|
+
"mode": { "enum": ["select-animation", "runtime-trigger"] },
|
|
70
|
+
"auto_play": { "type": "boolean", "default": true },
|
|
71
|
+
"review_required": { "type": "boolean", "default": true },
|
|
72
|
+
"wait_ms": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
|
73
|
+
"payload": { "type": "object" }
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"sequenceStep": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"additionalProperties": false,
|
|
79
|
+
"required": ["transition"],
|
|
80
|
+
"properties": {
|
|
81
|
+
"transition": { "$ref": "#/$defs/id" },
|
|
82
|
+
"wait_ms": { "type": "integer", "minimum": 0, "maximum": 10000 }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"sequence": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["id", "steps"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"id": { "$ref": "#/$defs/id" },
|
|
91
|
+
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
92
|
+
"description": { "type": "string", "maxLength": 500 },
|
|
93
|
+
"review_required": { "type": "boolean", "default": false },
|
|
94
|
+
"steps": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"minItems": 1,
|
|
97
|
+
"maxItems": 64,
|
|
98
|
+
"items": { "$ref": "#/$defs/sequenceStep" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://motionloom.dev/schemas/frame-generation-lock.schema.json",
|
|
4
|
+
"title": "MotionLoom Frame Generation Lock",
|
|
5
|
+
"description": "Provider-neutral, machine-readable generation constraints that keep independently generated animation frames on one identity, canvas and geometry lock before deterministic post-generation preflight.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"schema_version",
|
|
10
|
+
"lock_id",
|
|
11
|
+
"asset_identity",
|
|
12
|
+
"action_id",
|
|
13
|
+
"reference",
|
|
14
|
+
"canvas",
|
|
15
|
+
"geometry",
|
|
16
|
+
"appearance",
|
|
17
|
+
"source_policy",
|
|
18
|
+
"frames",
|
|
19
|
+
"postflight",
|
|
20
|
+
"trust"
|
|
21
|
+
],
|
|
22
|
+
"properties": {
|
|
23
|
+
"schema_version": { "const": "0.1" },
|
|
24
|
+
"lock_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,95}$" },
|
|
25
|
+
"asset_identity": { "type": "string", "minLength": 1 },
|
|
26
|
+
"action_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,63}$" },
|
|
27
|
+
"reference": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"required": ["image", "sha256", "role"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"image": { "type": "string", "minLength": 1 },
|
|
33
|
+
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
34
|
+
"role": { "enum": ["identity_anchor", "accepted_frame_anchor"] }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"canvas": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"required": ["width", "height", "color_space", "alpha_mode"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"width": { "type": "integer", "minimum": 1 },
|
|
43
|
+
"height": { "type": "integer", "minimum": 1 },
|
|
44
|
+
"color_space": { "enum": ["srgb", "linear-srgb"] },
|
|
45
|
+
"alpha_mode": { "enum": ["straight", "premultiplied"] }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"geometry": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"required": ["center_x", "pivot", "footline_px", "safe_rect", "min_padding_px", "target_alpha_bbox", "tolerances"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"center_x": { "type": "number" },
|
|
54
|
+
"pivot": { "$ref": "#/$defs/point" },
|
|
55
|
+
"footline_px": { "type": "number" },
|
|
56
|
+
"safe_rect": { "$ref": "#/$defs/rect" },
|
|
57
|
+
"min_padding_px": { "type": "integer", "minimum": 0 },
|
|
58
|
+
"target_alpha_bbox": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"required": ["width", "height"],
|
|
62
|
+
"properties": {
|
|
63
|
+
"width": { "type": "integer", "minimum": 1 },
|
|
64
|
+
"height": { "type": "integer", "minimum": 1 }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"tolerances": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"required": ["pivot_px", "footline_px", "bbox_width_px", "bbox_height_px"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"pivot_px": { "type": "number", "minimum": 0 },
|
|
73
|
+
"footline_px": { "type": "number", "minimum": 0 },
|
|
74
|
+
"bbox_width_px": { "type": "number", "minimum": 0 },
|
|
75
|
+
"bbox_height_px": { "type": "number", "minimum": 0 }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"appearance": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"additionalProperties": false,
|
|
83
|
+
"required": ["preserve", "forbid", "pixel_art"],
|
|
84
|
+
"properties": {
|
|
85
|
+
"preserve": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
86
|
+
"forbid": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
87
|
+
"pixel_art": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"required": ["enabled", "nearest_neighbor_only"],
|
|
91
|
+
"properties": {
|
|
92
|
+
"enabled": { "type": "boolean" },
|
|
93
|
+
"nearest_neighbor_only": { "type": "boolean" }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"source_policy": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": false,
|
|
101
|
+
"required": ["isolated_frames", "max_frames_per_image", "allow_pose_sheet", "allow_post_resize", "reuse_reference"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"isolated_frames": { "const": true },
|
|
104
|
+
"max_frames_per_image": { "const": 1 },
|
|
105
|
+
"allow_pose_sheet": { "const": false },
|
|
106
|
+
"allow_post_resize": { "const": false },
|
|
107
|
+
"reuse_reference": { "const": true }
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"frames": {
|
|
111
|
+
"type": "array",
|
|
112
|
+
"minItems": 2,
|
|
113
|
+
"items": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"additionalProperties": false,
|
|
116
|
+
"required": ["frame_id", "pose", "output"],
|
|
117
|
+
"properties": {
|
|
118
|
+
"frame_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,63}$" },
|
|
119
|
+
"pose": { "type": "string", "minLength": 1 },
|
|
120
|
+
"output": { "type": "string", "pattern": ".+\\.png$" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"postflight": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"additionalProperties": false,
|
|
127
|
+
"required": ["frame_geometry"],
|
|
128
|
+
"properties": {
|
|
129
|
+
"frame_geometry": { "type": "string", "minLength": 1 }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"trust": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"additionalProperties": false,
|
|
135
|
+
"required": ["authority", "review_only", "approval"],
|
|
136
|
+
"properties": {
|
|
137
|
+
"authority": { "enum": ["ai_generated", "ai_assisted", "code_authored", "unknown"] },
|
|
138
|
+
"review_only": { "const": true },
|
|
139
|
+
"approval": { "const": false }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"$defs": {
|
|
144
|
+
"rect": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"additionalProperties": false,
|
|
147
|
+
"required": ["x", "y", "width", "height"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"x": { "type": "integer", "minimum": 0 },
|
|
150
|
+
"y": { "type": "integer", "minimum": 0 },
|
|
151
|
+
"width": { "type": "integer", "minimum": 1 },
|
|
152
|
+
"height": { "type": "integer", "minimum": 1 }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"point": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"additionalProperties": false,
|
|
158
|
+
"required": ["x", "y", "space"],
|
|
159
|
+
"properties": {
|
|
160
|
+
"x": { "type": "number" },
|
|
161
|
+
"y": { "type": "number" },
|
|
162
|
+
"space": { "enum": ["pixels", "normalized"] }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate and compose provider-neutral instructions from a MotionLoom Frame Generation Lock.
|
|
3
|
+
|
|
4
|
+
The lock exists to keep independently generated animation frames on one identity,
|
|
5
|
+
canvas and geometry contract before deterministic post-generation preflight. This
|
|
6
|
+
tool does not call an image provider, modify assets, grant provenance authority or
|
|
7
|
+
mint user approval.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,95}$")
|
|
22
|
+
FRAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,63}$")
|
|
23
|
+
SHA_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def issue(code: str, message: str, path: str = "") -> dict[str, str]:
|
|
27
|
+
return {"severity": "error", "code": code, "message": message, "path": path}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_document(path: Path) -> tuple[dict[str, Any] | None, list[dict[str, str]]]:
|
|
31
|
+
try:
|
|
32
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
34
|
+
return None, [issue("invalid_input", str(exc), str(path))]
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
return None, [issue("invalid_document", "lock root must be an object", str(path))]
|
|
37
|
+
return value, []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def inside(root: Path, value: str) -> tuple[Path | None, dict[str, str] | None]:
|
|
41
|
+
candidate = (root / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
|
|
42
|
+
try:
|
|
43
|
+
candidate.relative_to(root)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None, issue("path_escape", f"path escapes lock root: {value}", value)
|
|
46
|
+
return candidate, None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def rect_inside(rect: dict[str, Any], width: int, height: int) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
x, y = int(rect["x"]), int(rect["y"])
|
|
52
|
+
w, h = int(rect["width"]), int(rect["height"])
|
|
53
|
+
except (KeyError, TypeError, ValueError):
|
|
54
|
+
return False
|
|
55
|
+
return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= width and y + h <= height
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate(document: dict[str, Any], root: Path) -> dict[str, Any]:
|
|
59
|
+
errors: list[dict[str, str]] = []
|
|
60
|
+
required = [
|
|
61
|
+
"schema_version", "lock_id", "asset_identity", "action_id", "reference",
|
|
62
|
+
"canvas", "geometry", "appearance", "source_policy", "frames", "postflight", "trust",
|
|
63
|
+
]
|
|
64
|
+
for key in required:
|
|
65
|
+
if key not in document:
|
|
66
|
+
errors.append(issue("missing_field", f"required field is missing: {key}", key))
|
|
67
|
+
|
|
68
|
+
if document.get("schema_version") != "0.1":
|
|
69
|
+
errors.append(issue("schema_version", "frame generation lock schema_version must be 0.1", "schema_version"))
|
|
70
|
+
if not ID_RE.match(str(document.get("lock_id", ""))):
|
|
71
|
+
errors.append(issue("invalid_lock_id", "lock_id must use lowercase safe identifier characters", "lock_id"))
|
|
72
|
+
if not FRAME_RE.match(str(document.get("action_id", ""))):
|
|
73
|
+
errors.append(issue("invalid_action_id", "action_id must use lowercase safe identifier characters", "action_id"))
|
|
74
|
+
|
|
75
|
+
canvas = document.get("canvas") if isinstance(document.get("canvas"), dict) else {}
|
|
76
|
+
try:
|
|
77
|
+
width, height = int(canvas.get("width", 0)), int(canvas.get("height", 0))
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
width, height = 0, 0
|
|
80
|
+
if width <= 0 or height <= 0:
|
|
81
|
+
errors.append(issue("invalid_canvas", "canvas width and height must be positive integers", "canvas"))
|
|
82
|
+
if canvas.get("color_space") not in {"srgb", "linear-srgb"}:
|
|
83
|
+
errors.append(issue("invalid_color_space", "unsupported canvas color_space", "canvas.color_space"))
|
|
84
|
+
if canvas.get("alpha_mode") not in {"straight", "premultiplied"}:
|
|
85
|
+
errors.append(issue("invalid_alpha_mode", "unsupported canvas alpha_mode", "canvas.alpha_mode"))
|
|
86
|
+
|
|
87
|
+
reference = document.get("reference") if isinstance(document.get("reference"), dict) else {}
|
|
88
|
+
ref_value = str(reference.get("image", ""))
|
|
89
|
+
ref_path, path_error = inside(root, ref_value) if ref_value else (None, issue("missing_reference", "reference.image is required", "reference.image"))
|
|
90
|
+
if path_error:
|
|
91
|
+
errors.append(path_error)
|
|
92
|
+
expected_hash = str(reference.get("sha256", ""))
|
|
93
|
+
if not SHA_RE.match(expected_hash):
|
|
94
|
+
errors.append(issue("invalid_reference_sha256", "reference.sha256 must be 64 lowercase hex characters", "reference.sha256"))
|
|
95
|
+
if ref_path is not None:
|
|
96
|
+
try:
|
|
97
|
+
actual_hash = hashlib.sha256(ref_path.read_bytes()).hexdigest()
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
errors.append(issue("reference_unreadable", str(exc), "reference.image"))
|
|
100
|
+
else:
|
|
101
|
+
if expected_hash and actual_hash != expected_hash:
|
|
102
|
+
errors.append(issue("reference_sha256_mismatch", "reference bytes do not match the locked SHA-256", "reference.sha256"))
|
|
103
|
+
if reference.get("role") not in {"identity_anchor", "accepted_frame_anchor"}:
|
|
104
|
+
errors.append(issue("invalid_reference_role", "reference.role must be identity_anchor or accepted_frame_anchor", "reference.role"))
|
|
105
|
+
|
|
106
|
+
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
|
|
107
|
+
safe_rect = geometry.get("safe_rect") if isinstance(geometry.get("safe_rect"), dict) else {}
|
|
108
|
+
if width > 0 and height > 0 and not rect_inside(safe_rect, width, height):
|
|
109
|
+
errors.append(issue("invalid_safe_rect", "geometry.safe_rect must fit inside the locked canvas", "geometry.safe_rect"))
|
|
110
|
+
target = geometry.get("target_alpha_bbox") if isinstance(geometry.get("target_alpha_bbox"), dict) else {}
|
|
111
|
+
try:
|
|
112
|
+
target_w, target_h = int(target.get("width", 0)), int(target.get("height", 0))
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
target_w, target_h = 0, 0
|
|
115
|
+
if target_w <= 0 or target_h <= 0 or (width > 0 and target_w > width) or (height > 0 and target_h > height):
|
|
116
|
+
errors.append(issue("invalid_target_bbox", "target alpha bbox must be positive and fit inside the canvas", "geometry.target_alpha_bbox"))
|
|
117
|
+
try:
|
|
118
|
+
min_padding = int(geometry.get("min_padding_px", -1))
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
min_padding = -1
|
|
121
|
+
if min_padding < 0:
|
|
122
|
+
errors.append(issue("invalid_padding", "geometry.min_padding_px must be non-negative", "geometry.min_padding_px"))
|
|
123
|
+
tolerances = geometry.get("tolerances") if isinstance(geometry.get("tolerances"), dict) else {}
|
|
124
|
+
for key in ("pivot_px", "footline_px", "bbox_width_px", "bbox_height_px"):
|
|
125
|
+
try:
|
|
126
|
+
value = float(tolerances.get(key, -1))
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
value = -1
|
|
129
|
+
if value < 0:
|
|
130
|
+
errors.append(issue("invalid_tolerance", f"geometry.tolerances.{key} must be non-negative", f"geometry.tolerances.{key}"))
|
|
131
|
+
|
|
132
|
+
source = document.get("source_policy") if isinstance(document.get("source_policy"), dict) else {}
|
|
133
|
+
expected_source = {
|
|
134
|
+
"isolated_frames": True,
|
|
135
|
+
"max_frames_per_image": 1,
|
|
136
|
+
"allow_pose_sheet": False,
|
|
137
|
+
"allow_post_resize": False,
|
|
138
|
+
"reuse_reference": True,
|
|
139
|
+
}
|
|
140
|
+
for key, expected in expected_source.items():
|
|
141
|
+
if source.get(key) != expected:
|
|
142
|
+
errors.append(issue("unsafe_source_policy", f"source_policy.{key} must be {expected!r}", f"source_policy.{key}"))
|
|
143
|
+
|
|
144
|
+
appearance = document.get("appearance") if isinstance(document.get("appearance"), dict) else {}
|
|
145
|
+
for key in ("preserve", "forbid"):
|
|
146
|
+
values = appearance.get(key)
|
|
147
|
+
if not isinstance(values, list) or not values or any(not isinstance(value, str) or not value.strip() for value in values):
|
|
148
|
+
errors.append(issue("invalid_appearance_rule", f"appearance.{key} must be a non-empty string array", f"appearance.{key}"))
|
|
149
|
+
pixel_art = appearance.get("pixel_art") if isinstance(appearance.get("pixel_art"), dict) else {}
|
|
150
|
+
if pixel_art.get("enabled") is True and pixel_art.get("nearest_neighbor_only") is not True:
|
|
151
|
+
errors.append(issue("unsafe_pixel_art_policy", "pixel-art locks require nearest_neighbor_only=true", "appearance.pixel_art.nearest_neighbor_only"))
|
|
152
|
+
|
|
153
|
+
frames = document.get("frames") if isinstance(document.get("frames"), list) else []
|
|
154
|
+
if len(frames) < 2:
|
|
155
|
+
errors.append(issue("insufficient_frames", "a frame generation lock requires at least two frames", "frames"))
|
|
156
|
+
seen_ids: set[str] = set()
|
|
157
|
+
seen_outputs: set[str] = set()
|
|
158
|
+
for index, frame in enumerate(frames):
|
|
159
|
+
prefix = f"frames[{index}]"
|
|
160
|
+
if not isinstance(frame, dict):
|
|
161
|
+
errors.append(issue("invalid_frame", "frame must be an object", prefix))
|
|
162
|
+
continue
|
|
163
|
+
frame_id = str(frame.get("frame_id", ""))
|
|
164
|
+
if not FRAME_RE.match(frame_id):
|
|
165
|
+
errors.append(issue("invalid_frame_id", "frame_id must use lowercase safe identifier characters", f"{prefix}.frame_id"))
|
|
166
|
+
if frame_id in seen_ids:
|
|
167
|
+
errors.append(issue("duplicate_frame_id", f"duplicate frame_id: {frame_id}", f"{prefix}.frame_id"))
|
|
168
|
+
seen_ids.add(frame_id)
|
|
169
|
+
if not str(frame.get("pose", "")).strip():
|
|
170
|
+
errors.append(issue("missing_pose", "frame pose instruction is required", f"{prefix}.pose"))
|
|
171
|
+
output = str(frame.get("output", ""))
|
|
172
|
+
if not output.lower().endswith(".png"):
|
|
173
|
+
errors.append(issue("invalid_output", "frame output must be a PNG path", f"{prefix}.output"))
|
|
174
|
+
if output in seen_outputs:
|
|
175
|
+
errors.append(issue("duplicate_output", f"multiple frames target the same output: {output}", f"{prefix}.output"))
|
|
176
|
+
seen_outputs.add(output)
|
|
177
|
+
_, output_error = inside(root, output) if output else (None, issue("invalid_output", "frame output is required", f"{prefix}.output"))
|
|
178
|
+
if output_error:
|
|
179
|
+
output_error["path"] = f"{prefix}.output"
|
|
180
|
+
errors.append(output_error)
|
|
181
|
+
|
|
182
|
+
postflight = document.get("postflight") if isinstance(document.get("postflight"), dict) else {}
|
|
183
|
+
geometry_value = str(postflight.get("frame_geometry", ""))
|
|
184
|
+
_, geometry_error = inside(root, geometry_value) if geometry_value else (None, issue("missing_postflight", "postflight.frame_geometry is required", "postflight.frame_geometry"))
|
|
185
|
+
if geometry_error:
|
|
186
|
+
geometry_error["path"] = "postflight.frame_geometry"
|
|
187
|
+
errors.append(geometry_error)
|
|
188
|
+
|
|
189
|
+
trust = document.get("trust") if isinstance(document.get("trust"), dict) else {}
|
|
190
|
+
if trust.get("review_only") is not True or trust.get("approval") is not False:
|
|
191
|
+
errors.append(issue("invalid_trust_boundary", "generation locks must remain review_only with approval=false", "trust"))
|
|
192
|
+
if trust.get("authority") not in {"ai_generated", "ai_assisted", "code_authored", "unknown"}:
|
|
193
|
+
errors.append(issue("invalid_authority", "unsupported trust.authority", "trust.authority"))
|
|
194
|
+
|
|
195
|
+
canonical = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
196
|
+
return {
|
|
197
|
+
"contract": "frame_generation_lock",
|
|
198
|
+
"ready": not errors,
|
|
199
|
+
"errors": errors,
|
|
200
|
+
"warnings": [],
|
|
201
|
+
"metrics": {
|
|
202
|
+
"frame_count": len(frames),
|
|
203
|
+
"canvas": {"width": width, "height": height},
|
|
204
|
+
"lock_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
205
|
+
"reference_sha256": expected_hash or None,
|
|
206
|
+
"isolated_frames": source.get("isolated_frames") is True,
|
|
207
|
+
},
|
|
208
|
+
"approval": False,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def find_frame(document: dict[str, Any], frame_id: str) -> dict[str, Any] | None:
|
|
213
|
+
for frame in document.get("frames", []):
|
|
214
|
+
if isinstance(frame, dict) and frame.get("frame_id") == frame_id:
|
|
215
|
+
return frame
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compose_instruction(document: dict[str, Any], frame: dict[str, Any]) -> str:
|
|
220
|
+
canvas = document["canvas"]
|
|
221
|
+
geometry = document["geometry"]
|
|
222
|
+
appearance = document["appearance"]
|
|
223
|
+
reference = document["reference"]
|
|
224
|
+
target = geometry["target_alpha_bbox"]
|
|
225
|
+
tolerances = geometry["tolerances"]
|
|
226
|
+
safe = geometry["safe_rect"]
|
|
227
|
+
pivot = geometry["pivot"]
|
|
228
|
+
preserve = "; ".join(str(value).strip() for value in appearance["preserve"])
|
|
229
|
+
forbid = "; ".join(str(value).strip() for value in appearance["forbid"])
|
|
230
|
+
pixel_rule = " Use crisp nearest-neighbor pixel edges; do not resample or blur." if appearance.get("pixel_art", {}).get("enabled") else ""
|
|
231
|
+
return (
|
|
232
|
+
f"MotionLoom Frame Generation Lock {document['lock_id']} for action {document['action_id']}. "
|
|
233
|
+
f"Use reference image {reference['image']} as the locked {reference['role']} with SHA-256 {reference['sha256']}. "
|
|
234
|
+
f"Generate exactly ONE isolated source frame for {frame['frame_id']}; never create a pose sheet, contact sheet, collage, atlas, or multiple poses in one image. "
|
|
235
|
+
f"Pose: {frame['pose']} "
|
|
236
|
+
f"Canvas is exactly {canvas['width']} × {canvas['height']} pixels, {canvas['color_space']} with {canvas['alpha_mode']} alpha. "
|
|
237
|
+
f"Keep the subject centered near x={geometry['center_x']}; pivot={pivot['x']},{pivot['y']} ({pivot['space']}); footline={geometry['footline_px']} px. "
|
|
238
|
+
f"Keep all opaque pixels inside safe rect x={safe['x']}, y={safe['y']}, width={safe['width']}, height={safe['height']} and preserve at least {geometry['min_padding_px']} px transparent padding. "
|
|
239
|
+
f"Target apparent alpha bounds are approximately {target['width']} × {target['height']} px; do not introduce whole-subject zoom drift beyond ±{tolerances['bbox_width_px']} px width or ±{tolerances['bbox_height_px']} px height, pivot drift beyond ±{tolerances['pivot_px']} px, or footline drift beyond ±{tolerances['footline_px']} px. "
|
|
240
|
+
f"Preserve: {preserve}. Forbid: {forbid}.{pixel_rule} "
|
|
241
|
+
f"Do not mirror, crop from a shared canvas, silently change camera/scale, or resize the generated frame afterward. "
|
|
242
|
+
f"Return/save only the single PNG as {frame['output']}. This is review evidence only; generation success does not imply artist authorship, production eligibility, runtime approval, licence, or user approval."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def compose(document: dict[str, Any], root: Path, frame_id: str | None = None) -> dict[str, Any]:
|
|
247
|
+
validation = validate(document, root)
|
|
248
|
+
if not validation["ready"]:
|
|
249
|
+
return validation
|
|
250
|
+
frames = document["frames"] if frame_id is None else [find_frame(document, frame_id)]
|
|
251
|
+
if frame_id is not None and frames[0] is None:
|
|
252
|
+
return {
|
|
253
|
+
"contract": "frame_generation_lock",
|
|
254
|
+
"ready": False,
|
|
255
|
+
"errors": [issue("unknown_frame", f"frame_id not found in lock: {frame_id}", "frame_id")],
|
|
256
|
+
"warnings": [],
|
|
257
|
+
"metrics": validation["metrics"],
|
|
258
|
+
"approval": False,
|
|
259
|
+
}
|
|
260
|
+
postflight = document["postflight"]["frame_geometry"]
|
|
261
|
+
items = [
|
|
262
|
+
{
|
|
263
|
+
"frame_id": frame["frame_id"],
|
|
264
|
+
"output": frame["output"],
|
|
265
|
+
"instruction": compose_instruction(document, frame),
|
|
266
|
+
}
|
|
267
|
+
for frame in frames
|
|
268
|
+
if isinstance(frame, dict)
|
|
269
|
+
]
|
|
270
|
+
return {
|
|
271
|
+
"contract": "frame_generation_lock",
|
|
272
|
+
"ready": True,
|
|
273
|
+
"lock_id": document["lock_id"],
|
|
274
|
+
"action_id": document["action_id"],
|
|
275
|
+
"lock_sha256": validation["metrics"]["lock_sha256"],
|
|
276
|
+
"reference_sha256": validation["metrics"]["reference_sha256"],
|
|
277
|
+
"frames": items,
|
|
278
|
+
"next_gate": f"motionloom frame-set-preflight --input {postflight} --root {root} --json",
|
|
279
|
+
"approval": False,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def emit(result: dict[str, Any], as_json: bool) -> None:
|
|
284
|
+
if as_json:
|
|
285
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
286
|
+
return
|
|
287
|
+
if not result.get("ready"):
|
|
288
|
+
print("frame generation lock: FAIL")
|
|
289
|
+
for item in result.get("errors", []):
|
|
290
|
+
print(f"ERROR {item.get('code')}: {item.get('message')}")
|
|
291
|
+
return
|
|
292
|
+
if "frames" not in result:
|
|
293
|
+
print("frame generation lock: PASS")
|
|
294
|
+
return
|
|
295
|
+
for frame in result["frames"]:
|
|
296
|
+
print(frame["instruction"])
|
|
297
|
+
print()
|
|
298
|
+
print(f"Next gate: {result['next_gate']}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: list[str] | None = None) -> int:
|
|
302
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
303
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
304
|
+
for name in ("validate", "compose", "compose-all"):
|
|
305
|
+
command = sub.add_parser(name)
|
|
306
|
+
command.add_argument("--input", required=True, help="frame-generation-lock JSON")
|
|
307
|
+
command.add_argument("--root", default=".", help="project/asset root used to resolve paths")
|
|
308
|
+
command.add_argument("--json", action="store_true", dest="as_json")
|
|
309
|
+
if name == "compose":
|
|
310
|
+
command.add_argument("--frame-id", required=True)
|
|
311
|
+
args = parser.parse_args(argv)
|
|
312
|
+
document, load_errors = load_document(Path(args.input))
|
|
313
|
+
if load_errors or document is None:
|
|
314
|
+
result = {"contract": "frame_generation_lock", "ready": False, "errors": load_errors, "warnings": [], "metrics": {}, "approval": False}
|
|
315
|
+
else:
|
|
316
|
+
root = Path(args.root).resolve()
|
|
317
|
+
if args.command == "validate":
|
|
318
|
+
result = validate(document, root)
|
|
319
|
+
elif args.command == "compose":
|
|
320
|
+
result = compose(document, root, args.frame_id)
|
|
321
|
+
else:
|
|
322
|
+
result = compose(document, root)
|
|
323
|
+
emit(result, args.as_json)
|
|
324
|
+
return 0 if result.get("ready") else 1
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main())
|