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,109 @@
1
+ # Multi-frame asset generation discipline
2
+
3
+ This contract applies whenever MotionLoom or an Agent creates two or more source frames for the same character, object, effect or other frame-based animation. It is automatic: the user should not have to ask for consistent frame geometry, isolated source frames or atlas hygiene.
4
+
5
+ ## Default source policy
6
+
7
+ Generated source frames are **isolated-frame assets**, not a pose sheet.
8
+
9
+ - Generate exactly one animation source frame per image/canvas by default.
10
+ - For long actions (including actions with six or more frames), never ask an image generator for one contact sheet, sprite sheet or multi-pose canvas and then crop the poses out as production source frames.
11
+ - Do not pack an atlas or sprite sheet until every isolated source frame has passed identity and frame-geometry validation.
12
+ - Imported third-party atlases may still use the atlas contract; this rule is about Agent-generated source material.
13
+
14
+ If a generator cannot preserve isolated source canvases reliably, regenerate in smaller isolated calls or use provider outputs that are already separate files. Do not convert one contaminated multi-pose canvas into accepted source frames merely because crops can be extracted from it.
15
+
16
+ This avoids neighboring poses leaking into a crop, shared-canvas bleed, accidental partial limbs from adjacent frames and ambiguous ownership of opaque pixels.
17
+
18
+ ## Lock geometry before the second frame
19
+
20
+ Before generating frame 2, establish one canonical geometry lock from the accepted identity/reference frame and keep it unchanged for the rest of the action:
21
+
22
+ - exact canvas width and height;
23
+ - transparent alpha mode and color space;
24
+ - camera and left/right orientation;
25
+ - target apparent character scale;
26
+ - pivot point;
27
+ - baseline / footline;
28
+ - safe rectangle and transparent guard band;
29
+ - palette/style/identity reference;
30
+ - frame naming and action order.
31
+
32
+ Create or update the matching `action-set` and `frame-geometry` contracts before continuing the sequence. The first accepted frame is the geometry anchor; later poses may move limbs, but they must not silently change canvas size, camera, character scale, pivot or baseline.
33
+
34
+ For AI-generated frames, every subsequent generation request should carry the same locked identity reference and geometry requirements. When the image tool can accept an image reference, reuse the accepted identity/anchor frame rather than relying on prose alone.
35
+
36
+ ## Frame Generation Lock
37
+
38
+ Do not rely on the Agent remembering the same geometry prose across many provider calls. Persist the generation-side constraints in `frame-generation-lock.json` using `schemas/frame-generation-lock.schema.json`.
39
+
40
+ The lock binds:
41
+
42
+ - the exact identity/accepted-frame reference path and SHA-256;
43
+ - canvas dimensions, color space and alpha mode;
44
+ - center, pivot, footline, safe rectangle and minimum transparent padding;
45
+ - target measured alpha-bounds plus permitted width/height drift;
46
+ - appearance features that must be preserved and changes that are forbidden;
47
+ - a hard source policy of one isolated frame per image, no pose sheet and no post-generation resize;
48
+ - every `frame_id`, its pose intent and its unique PNG output path;
49
+ - the post-generation `frame-geometry` contract used by deterministic preflight;
50
+ - a review-only trust boundary with `approval: false`.
51
+
52
+ Before a provider call, validate the lock and compose the exact frame instruction from it:
53
+
54
+ ```bash
55
+ motionloom frame-generation-lock validate \
56
+ --input src/output/<scene>/<action>-frame-generation-lock.json \
57
+ --root src/output/<scene> --json
58
+
59
+ motionloom frame-generation-lock compose \
60
+ --input src/output/<scene>/<action>-frame-generation-lock.json \
61
+ --root src/output/<scene> --frame-id <action.frame-id> --json
62
+ ```
63
+
64
+ For batch planning, `compose-all` emits one independent instruction per frame while preserving the same lock hash and reference hash. It does **not** authorize asking a provider for one multi-frame canvas; each returned instruction still represents one isolated source image.
65
+
66
+ If the reference bytes change, an output path escapes the asset root, two frames target the same PNG, the lock permits a pose sheet/post-resize, or the trust boundary is weakened, validation fails closed before generation.
67
+
68
+ ## Validate incrementally
69
+
70
+ Do not generate the complete action and only inspect it at the end. After each candidate frame:
71
+
72
+ 1. verify the image is an isolated source canvas rather than a shared pose sheet;
73
+ 2. verify exact canvas dimensions and alpha transparency;
74
+ 3. measure the real alpha bounding box from pixels;
75
+ 4. compare apparent size against the anchor within the declared `bbox_drift_tolerance_px`;
76
+ 5. verify pivot and footline tolerance;
77
+ 6. verify the alpha bounding box remains inside `safe_rect` with the declared `bleed_margin_px` guard band;
78
+ 7. reject unexpected opaque contamination;
79
+ 8. only then accept the frame and continue.
80
+
81
+ Use:
82
+
83
+ ```bash
84
+ motionloom frame-set-preflight \
85
+ --input src/output/<scene>/<action>-frame-geometry.json \
86
+ --root src/output/<scene> --json
87
+ ```
88
+
89
+ The preflight is intentionally stricter than a visual warning: shared source images, non-isolated frame rectangles, scale drift beyond the declared tolerance, guard-band violations, pivot/footline drift, hash mismatch or other deterministic frame-geometry failures block the sequence.
90
+
91
+ If one frame fails, regenerate or repair **that frame only** using the same generation lock. Do not silently rescale every previously accepted frame to match a bad frame, and do not weaken tolerances merely to make the set pass.
92
+
93
+ ## Apparent-size consistency
94
+
95
+ Equal canvas dimensions are not enough. A 1920 × 1920 PNG can still contain a character that is 20% smaller than the previous frame. MotionLoom therefore treats measured alpha-bounds drift as a real source defect when it exceeds the declared tolerance.
96
+
97
+ Pose motion can legitimately change the bounding box, so choose a tolerance that allows expected limb extension while preventing whole-character zoom drift. For actions with large reaches, jumps or weapons, lock the body pivot/footline and define a safe rectangle large enough for those poses instead of changing the character scale between frames.
98
+
99
+ For pixel art, any intentional normalization step must preserve nearest-neighbor pixel density and the locked pivot/baseline. Large scale corrections should trigger regeneration rather than interpolation.
100
+
101
+ ## Packing happens last
102
+
103
+ Only after every source frame passes preflight may the Agent build a sprite sheet or atlas. The packed result must then pass the atlas contract: no region overlap, no opaque pixels outside declared regions when required, explicit padding/extrusion, and no ambiguous neighboring-frame bleed.
104
+
105
+ Source-frame acceptance and atlas acceptance are separate gates. A clean atlas cannot repair inconsistent source-frame scale, and consistent source frames do not prove the packed atlas is clean.
106
+
107
+ ## Agent decision rule
108
+
109
+ When the user asks for an animation such as idle, walk, run, attack, hurt, jump or any project-defined multi-frame action, the Agent should apply this contract automatically. Do not ask the user whether they want frame consistency checks or a generation lock; they are part of the default MotionLoom workflow. Only ask the user when a genuine artistic decision is required, such as choosing between materially different silhouettes or motion intent.
@@ -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())