motionloom 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +68 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +20 -4
- package/SECURITY.md +5 -5
- package/SKILL.md +15 -5
- package/agent-card.json +77 -14
- package/agent-surfaces.json +30 -7
- package/artifact-adapter-registry.json +568 -20
- package/bin/motionloom.mjs +25 -2
- package/capability-registry.json +58 -234
- package/dev-lab/public/devlab.js +36 -3
- package/dev-lab/public/index.html +6 -2
- package/docs/ACTION-SEPARATION.md +104 -0
- package/docs/ASSET-GENERATION-PLANNER.md +101 -0
- package/docs/BRANCH-PROTECTION.md +44 -0
- package/docs/EXTERNAL-CORPUS.md +26 -0
- package/docs/STATUS.md +3 -2
- package/docs/audits/field-test-after-hardening-2026-08-21.md +25 -0
- package/docs/releases/2.6.1.md +57 -0
- package/docs/releases/2.7.0.md +98 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
- package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
- package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
- package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
- package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +97 -0
- package/package.json +23 -9
- package/references/multi-frame-asset-generation.md +144 -0
- package/schemas/action-separation-verifier-evidence.schema.json +43 -0
- package/schemas/action-sequence-manifest.schema.json +48 -0
- package/schemas/artifact-adapter-registry.schema.json +1 -1
- package/schemas/asset-adaptation.schema.json +21 -0
- package/schemas/asset-generation-plan.schema.json +77 -0
- package/schemas/asset-generation-request.schema.json +104 -0
- package/schemas/frame-envelope.schema.json +62 -0
- package/schemas/frame-generation-lock.schema.json +189 -0
- package/scripts/action-separation.py +410 -0
- package/scripts/asset-adapt.mjs +92 -0
- package/scripts/asset-generation-plan.py +613 -0
- package/scripts/browser_review_consistency.py +64 -0
- package/scripts/fetch-project-corpus.py +91 -0
- package/scripts/frame-generation-lock.py +358 -0
- package/scripts/frame-set-preflight.py +264 -0
- package/scripts/package-consumer-smoke.mjs +20 -0
- package/scripts/quality-gate.py +7 -0
- package/scripts/release-verify.py +25 -0
- package/scripts/report-contract.py +1 -1
- package/scripts/report.py +19 -4
- package/scripts/resolve-task-bundle.py +11 -3
- package/scripts/review-hook.py +51 -2
- package/scripts/skill-doctor.py +42 -0
- package/src/output/browser-review-smoke/browser-review.json +6 -6
- package/tests/scripts/run_tests.py +45 -2
- package/tests/scripts/test_asset_adapt.py +47 -0
- package/tests/scripts/test_asset_consistency.py +77 -4
- package/tests/scripts/test_asset_generation_plan.py +250 -0
- package/tests/scripts/test_attestation.py +24 -8
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared browser-review candidate consistency checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# These fields determine which exact candidate was reviewed. The URL is
|
|
11
|
+
# intentionally excluded because a Dev Lab deployment may legitimately use a
|
|
12
|
+
# different host/base path while the candidate bytes remain identical.
|
|
13
|
+
IDENTITY_FIELDS = (
|
|
14
|
+
"candidate_id",
|
|
15
|
+
"task_id",
|
|
16
|
+
"scene",
|
|
17
|
+
"source_sha256",
|
|
18
|
+
"context_sha256",
|
|
19
|
+
"status",
|
|
20
|
+
"expires_at",
|
|
21
|
+
"runtime_review",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
26
|
+
try:
|
|
27
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
28
|
+
except (OSError, json.JSONDecodeError):
|
|
29
|
+
return {}
|
|
30
|
+
return value if isinstance(value, dict) else {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def candidate_consistency_errors(
|
|
34
|
+
scene_candidate_path: Path,
|
|
35
|
+
task_candidate_path: Path,
|
|
36
|
+
*,
|
|
37
|
+
expected_task_id: str | None = None,
|
|
38
|
+
expected_scene: str | None = None,
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""Return fail-closed errors for scene/task browser-review divergence."""
|
|
41
|
+
|
|
42
|
+
errors: list[str] = []
|
|
43
|
+
if not scene_candidate_path.is_file():
|
|
44
|
+
return ["scene-level browser-review.json is required when a task bundle is used"]
|
|
45
|
+
if not task_candidate_path.is_file():
|
|
46
|
+
return ["task-level browser-review.json is required when a task bundle is used"]
|
|
47
|
+
|
|
48
|
+
scene_candidate = read_json(scene_candidate_path)
|
|
49
|
+
task_candidate = read_json(task_candidate_path)
|
|
50
|
+
if not scene_candidate:
|
|
51
|
+
errors.append("scene-level browser-review.json is invalid or empty")
|
|
52
|
+
if not task_candidate:
|
|
53
|
+
errors.append("task-level browser-review.json is invalid or empty")
|
|
54
|
+
|
|
55
|
+
for field in IDENTITY_FIELDS:
|
|
56
|
+
if scene_candidate.get(field) != task_candidate.get(field):
|
|
57
|
+
errors.append(f"scene/task browser-review {field} mismatch")
|
|
58
|
+
|
|
59
|
+
if expected_task_id is not None and task_candidate.get("task_id") != expected_task_id:
|
|
60
|
+
errors.append("task-level browser-review task_id does not match task.json")
|
|
61
|
+
if expected_scene is not None and task_candidate.get("scene") != expected_scene:
|
|
62
|
+
errors.append("task-level browser-review scene does not match scene directory")
|
|
63
|
+
|
|
64
|
+
return errors
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Fetch the explicitly requested external analyzer corpus without installing or executing it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
DEFAULT_MANIFEST = ROOT / "tests/evals/project-corpus.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def now() -> str:
|
|
17
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(command: list[str], cwd: Path | None = None) -> str:
|
|
21
|
+
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
22
|
+
if result.returncode != 0:
|
|
23
|
+
detail = (result.stderr or result.stdout).strip()
|
|
24
|
+
raise RuntimeError(f"{' '.join(command)} failed: {detail}")
|
|
25
|
+
return result.stdout.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_destination(workspace: Path, relative: str) -> Path:
|
|
29
|
+
destination = (workspace / relative).resolve()
|
|
30
|
+
if workspace.resolve() not in destination.parents:
|
|
31
|
+
raise ValueError(f"manifest local_path escapes workspace: {relative}")
|
|
32
|
+
return destination
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> int:
|
|
36
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
37
|
+
parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
|
|
38
|
+
parser.add_argument("--workspace", required=True, help="Directory that will contain external checkouts")
|
|
39
|
+
parser.add_argument("--project", action="append", default=[], help="Manifest project id; repeatable")
|
|
40
|
+
parser.add_argument("--all", action="store_true", help="Fetch every external project in the manifest")
|
|
41
|
+
parser.add_argument("--depth", type=int, default=1)
|
|
42
|
+
parser.add_argument("--refresh", action="store_true", help="Remove and re-fetch existing selected checkouts")
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
if not args.all and not args.project:
|
|
45
|
+
parser.error("choose --all or at least one --project")
|
|
46
|
+
if args.depth < 1:
|
|
47
|
+
parser.error("--depth must be positive")
|
|
48
|
+
|
|
49
|
+
manifest_path = Path(args.manifest).expanduser().resolve()
|
|
50
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
51
|
+
cases = [case for case in manifest.get("projects", []) if case.get("external") is True]
|
|
52
|
+
selected = set(case["id"] for case in cases) if args.all else set(args.project)
|
|
53
|
+
known = {case["id"] for case in cases}
|
|
54
|
+
unknown = sorted(selected - known)
|
|
55
|
+
if unknown:
|
|
56
|
+
raise ValueError(f"unknown external project id(s): {', '.join(unknown)}")
|
|
57
|
+
|
|
58
|
+
workspace = Path(args.workspace).expanduser().resolve()
|
|
59
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
fetched: list[dict[str, str]] = []
|
|
61
|
+
for case in cases:
|
|
62
|
+
if case["id"] not in selected:
|
|
63
|
+
continue
|
|
64
|
+
destination = safe_destination(workspace, str(case["local_path"]))
|
|
65
|
+
if destination.exists() or destination.is_symlink():
|
|
66
|
+
if not args.refresh:
|
|
67
|
+
raise RuntimeError(f"checkout already exists: {destination}; pass --refresh to replace it")
|
|
68
|
+
if destination.is_symlink() or not destination.is_dir():
|
|
69
|
+
raise RuntimeError(f"refusing to remove non-directory checkout path: {destination}")
|
|
70
|
+
shutil.rmtree(destination)
|
|
71
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
source = str(case["source"])
|
|
73
|
+
run(["git", "clone", "--depth", str(args.depth), source, str(destination)])
|
|
74
|
+
commit = run(["git", "rev-parse", "HEAD"], cwd=destination)
|
|
75
|
+
fetched.append({"id": case["id"], "source": source, "path": str(destination), "commit": commit})
|
|
76
|
+
|
|
77
|
+
record = {
|
|
78
|
+
"schema_version": "1.0",
|
|
79
|
+
"manifest": str(manifest_path),
|
|
80
|
+
"fetched_at": now(),
|
|
81
|
+
"policy": "clone-only; no install, build, test or external code execution",
|
|
82
|
+
"projects": fetched,
|
|
83
|
+
}
|
|
84
|
+
record_path = workspace / ".motionloom-corpus-fetch.json"
|
|
85
|
+
record_path.write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
86
|
+
print(json.dumps({"status": "pass", "workspace": str(workspace), "projects": fetched, "record": str(record_path)}, ensure_ascii=False))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,358 @@
|
|
|
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") not in {"0.1", "0.2"}:
|
|
69
|
+
errors.append(issue("schema_version", "frame generation lock schema_version must be 0.1 or 0.2", "schema_version"))
|
|
70
|
+
enhanced = document.get("schema_version") == "0.2"
|
|
71
|
+
if enhanced:
|
|
72
|
+
if not ID_RE.match(str(document.get("sequence_id", ""))):
|
|
73
|
+
errors.append(issue("invalid_sequence_id", "schema_version 0.2 requires a safe sequence_id", "sequence_id"))
|
|
74
|
+
forbidden = document.get("forbidden_action_ids")
|
|
75
|
+
if not isinstance(forbidden, list) or not forbidden or any(not FRAME_RE.match(str(item)) for item in forbidden):
|
|
76
|
+
errors.append(issue("invalid_forbidden_actions", "schema_version 0.2 requires a non-empty safe forbidden_action_ids array", "forbidden_action_ids"))
|
|
77
|
+
elif str(document.get("action_id")) in {str(item) for item in forbidden}:
|
|
78
|
+
errors.append(issue("expected_action_forbidden", "action_id must not also be forbidden", "forbidden_action_ids"))
|
|
79
|
+
contract = document.get("action_contract") if isinstance(document.get("action_contract"), dict) else {}
|
|
80
|
+
action_manifest = str(document.get("action_manifest", ""))
|
|
81
|
+
if not action_manifest.lower().endswith(".json"):
|
|
82
|
+
errors.append(issue("invalid_action_manifest", "schema_version 0.2 requires a JSON action_manifest path", "action_manifest"))
|
|
83
|
+
else:
|
|
84
|
+
_, manifest_error = inside(root, action_manifest)
|
|
85
|
+
if manifest_error:
|
|
86
|
+
manifest_error["path"] = "action_manifest"
|
|
87
|
+
errors.append(manifest_error)
|
|
88
|
+
for key in ("positive_cues", "negative_cues"):
|
|
89
|
+
values = contract.get(key)
|
|
90
|
+
if not isinstance(values, list) or not values or any(not isinstance(item, str) or not item.strip() for item in values):
|
|
91
|
+
errors.append(issue("invalid_action_contract", f"action_contract.{key} must be a non-empty string array", f"action_contract.{key}"))
|
|
92
|
+
if not ID_RE.match(str(document.get("lock_id", ""))):
|
|
93
|
+
errors.append(issue("invalid_lock_id", "lock_id must use lowercase safe identifier characters", "lock_id"))
|
|
94
|
+
if not FRAME_RE.match(str(document.get("action_id", ""))):
|
|
95
|
+
errors.append(issue("invalid_action_id", "action_id must use lowercase safe identifier characters", "action_id"))
|
|
96
|
+
|
|
97
|
+
canvas = document.get("canvas") if isinstance(document.get("canvas"), dict) else {}
|
|
98
|
+
try:
|
|
99
|
+
width, height = int(canvas.get("width", 0)), int(canvas.get("height", 0))
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
width, height = 0, 0
|
|
102
|
+
if width <= 0 or height <= 0:
|
|
103
|
+
errors.append(issue("invalid_canvas", "canvas width and height must be positive integers", "canvas"))
|
|
104
|
+
if canvas.get("color_space") not in {"srgb", "linear-srgb"}:
|
|
105
|
+
errors.append(issue("invalid_color_space", "unsupported canvas color_space", "canvas.color_space"))
|
|
106
|
+
if canvas.get("alpha_mode") not in {"straight", "premultiplied"}:
|
|
107
|
+
errors.append(issue("invalid_alpha_mode", "unsupported canvas alpha_mode", "canvas.alpha_mode"))
|
|
108
|
+
|
|
109
|
+
reference = document.get("reference") if isinstance(document.get("reference"), dict) else {}
|
|
110
|
+
ref_value = str(reference.get("image", ""))
|
|
111
|
+
ref_path, path_error = inside(root, ref_value) if ref_value else (None, issue("missing_reference", "reference.image is required", "reference.image"))
|
|
112
|
+
if path_error:
|
|
113
|
+
errors.append(path_error)
|
|
114
|
+
expected_hash = str(reference.get("sha256", ""))
|
|
115
|
+
if not SHA_RE.match(expected_hash):
|
|
116
|
+
errors.append(issue("invalid_reference_sha256", "reference.sha256 must be 64 lowercase hex characters", "reference.sha256"))
|
|
117
|
+
if ref_path is not None:
|
|
118
|
+
try:
|
|
119
|
+
actual_hash = hashlib.sha256(ref_path.read_bytes()).hexdigest()
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
errors.append(issue("reference_unreadable", str(exc), "reference.image"))
|
|
122
|
+
else:
|
|
123
|
+
if expected_hash and actual_hash != expected_hash:
|
|
124
|
+
errors.append(issue("reference_sha256_mismatch", "reference bytes do not match the locked SHA-256", "reference.sha256"))
|
|
125
|
+
if reference.get("role") not in {"identity_anchor", "accepted_frame_anchor"}:
|
|
126
|
+
errors.append(issue("invalid_reference_role", "reference.role must be identity_anchor or accepted_frame_anchor", "reference.role"))
|
|
127
|
+
|
|
128
|
+
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
|
|
129
|
+
safe_rect = geometry.get("safe_rect") if isinstance(geometry.get("safe_rect"), dict) else {}
|
|
130
|
+
if width > 0 and height > 0 and not rect_inside(safe_rect, width, height):
|
|
131
|
+
errors.append(issue("invalid_safe_rect", "geometry.safe_rect must fit inside the locked canvas", "geometry.safe_rect"))
|
|
132
|
+
target = geometry.get("target_alpha_bbox") if isinstance(geometry.get("target_alpha_bbox"), dict) else {}
|
|
133
|
+
try:
|
|
134
|
+
target_w, target_h = int(target.get("width", 0)), int(target.get("height", 0))
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
target_w, target_h = 0, 0
|
|
137
|
+
if target_w <= 0 or target_h <= 0 or (width > 0 and target_w > width) or (height > 0 and target_h > height):
|
|
138
|
+
errors.append(issue("invalid_target_bbox", "target alpha bbox must be positive and fit inside the canvas", "geometry.target_alpha_bbox"))
|
|
139
|
+
try:
|
|
140
|
+
min_padding = int(geometry.get("min_padding_px", -1))
|
|
141
|
+
except (TypeError, ValueError):
|
|
142
|
+
min_padding = -1
|
|
143
|
+
if min_padding < 0:
|
|
144
|
+
errors.append(issue("invalid_padding", "geometry.min_padding_px must be non-negative", "geometry.min_padding_px"))
|
|
145
|
+
tolerances = geometry.get("tolerances") if isinstance(geometry.get("tolerances"), dict) else {}
|
|
146
|
+
for key in ("pivot_px", "footline_px", "bbox_width_px", "bbox_height_px"):
|
|
147
|
+
try:
|
|
148
|
+
value = float(tolerances.get(key, -1))
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
value = -1
|
|
151
|
+
if value < 0:
|
|
152
|
+
errors.append(issue("invalid_tolerance", f"geometry.tolerances.{key} must be non-negative", f"geometry.tolerances.{key}"))
|
|
153
|
+
|
|
154
|
+
source = document.get("source_policy") if isinstance(document.get("source_policy"), dict) else {}
|
|
155
|
+
expected_source = {
|
|
156
|
+
"isolated_frames": True,
|
|
157
|
+
"max_frames_per_image": 1,
|
|
158
|
+
"allow_pose_sheet": False,
|
|
159
|
+
"allow_post_resize": False,
|
|
160
|
+
"reuse_reference": True,
|
|
161
|
+
}
|
|
162
|
+
for key, expected in expected_source.items():
|
|
163
|
+
if source.get(key) != expected:
|
|
164
|
+
errors.append(issue("unsafe_source_policy", f"source_policy.{key} must be {expected!r}", f"source_policy.{key}"))
|
|
165
|
+
|
|
166
|
+
appearance = document.get("appearance") if isinstance(document.get("appearance"), dict) else {}
|
|
167
|
+
for key in ("preserve", "forbid"):
|
|
168
|
+
values = appearance.get(key)
|
|
169
|
+
if not isinstance(values, list) or not values or any(not isinstance(value, str) or not value.strip() for value in values):
|
|
170
|
+
errors.append(issue("invalid_appearance_rule", f"appearance.{key} must be a non-empty string array", f"appearance.{key}"))
|
|
171
|
+
pixel_art = appearance.get("pixel_art") if isinstance(appearance.get("pixel_art"), dict) else {}
|
|
172
|
+
if pixel_art.get("enabled") is True and pixel_art.get("nearest_neighbor_only") is not True:
|
|
173
|
+
errors.append(issue("unsafe_pixel_art_policy", "pixel-art locks require nearest_neighbor_only=true", "appearance.pixel_art.nearest_neighbor_only"))
|
|
174
|
+
|
|
175
|
+
frames = document.get("frames") if isinstance(document.get("frames"), list) else []
|
|
176
|
+
if len(frames) < 2:
|
|
177
|
+
errors.append(issue("insufficient_frames", "a frame generation lock requires at least two frames", "frames"))
|
|
178
|
+
seen_ids: set[str] = set()
|
|
179
|
+
seen_outputs: set[str] = set()
|
|
180
|
+
for index, frame in enumerate(frames):
|
|
181
|
+
prefix = f"frames[{index}]"
|
|
182
|
+
if not isinstance(frame, dict):
|
|
183
|
+
errors.append(issue("invalid_frame", "frame must be an object", prefix))
|
|
184
|
+
continue
|
|
185
|
+
frame_id = str(frame.get("frame_id", ""))
|
|
186
|
+
if not FRAME_RE.match(frame_id):
|
|
187
|
+
errors.append(issue("invalid_frame_id", "frame_id must use lowercase safe identifier characters", f"{prefix}.frame_id"))
|
|
188
|
+
if frame_id in seen_ids:
|
|
189
|
+
errors.append(issue("duplicate_frame_id", f"duplicate frame_id: {frame_id}", f"{prefix}.frame_id"))
|
|
190
|
+
seen_ids.add(frame_id)
|
|
191
|
+
if not str(frame.get("pose", "")).strip():
|
|
192
|
+
errors.append(issue("missing_pose", "frame pose instruction is required", f"{prefix}.pose"))
|
|
193
|
+
output = str(frame.get("output", ""))
|
|
194
|
+
if not output.lower().endswith(".png"):
|
|
195
|
+
errors.append(issue("invalid_output", "frame output must be a PNG path", f"{prefix}.output"))
|
|
196
|
+
if output in seen_outputs:
|
|
197
|
+
errors.append(issue("duplicate_output", f"multiple frames target the same output: {output}", f"{prefix}.output"))
|
|
198
|
+
seen_outputs.add(output)
|
|
199
|
+
_, output_error = inside(root, output) if output else (None, issue("invalid_output", "frame output is required", f"{prefix}.output"))
|
|
200
|
+
if output_error:
|
|
201
|
+
output_error["path"] = f"{prefix}.output"
|
|
202
|
+
errors.append(output_error)
|
|
203
|
+
|
|
204
|
+
postflight = document.get("postflight") if isinstance(document.get("postflight"), dict) else {}
|
|
205
|
+
geometry_value = str(postflight.get("frame_geometry", ""))
|
|
206
|
+
_, geometry_error = inside(root, geometry_value) if geometry_value else (None, issue("missing_postflight", "postflight.frame_geometry is required", "postflight.frame_geometry"))
|
|
207
|
+
if geometry_error:
|
|
208
|
+
geometry_error["path"] = "postflight.frame_geometry"
|
|
209
|
+
errors.append(geometry_error)
|
|
210
|
+
|
|
211
|
+
trust = document.get("trust") if isinstance(document.get("trust"), dict) else {}
|
|
212
|
+
if trust.get("review_only") is not True or trust.get("approval") is not False:
|
|
213
|
+
errors.append(issue("invalid_trust_boundary", "generation locks must remain review_only with approval=false", "trust"))
|
|
214
|
+
if trust.get("authority") not in {"ai_generated", "ai_assisted", "code_authored", "unknown"}:
|
|
215
|
+
errors.append(issue("invalid_authority", "unsupported trust.authority", "trust.authority"))
|
|
216
|
+
|
|
217
|
+
canonical = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
218
|
+
return {
|
|
219
|
+
"contract": "frame_generation_lock",
|
|
220
|
+
"ready": not errors,
|
|
221
|
+
"errors": errors,
|
|
222
|
+
"warnings": [],
|
|
223
|
+
"metrics": {
|
|
224
|
+
"frame_count": len(frames),
|
|
225
|
+
"canvas": {"width": width, "height": height},
|
|
226
|
+
"lock_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
227
|
+
"reference_sha256": expected_hash or None,
|
|
228
|
+
"isolated_frames": source.get("isolated_frames") is True,
|
|
229
|
+
},
|
|
230
|
+
"approval": False,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def find_frame(document: dict[str, Any], frame_id: str) -> dict[str, Any] | None:
|
|
235
|
+
for frame in document.get("frames", []):
|
|
236
|
+
if isinstance(frame, dict) and frame.get("frame_id") == frame_id:
|
|
237
|
+
return frame
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def compose_instruction(document: dict[str, Any], frame: dict[str, Any]) -> str:
|
|
242
|
+
canvas = document["canvas"]
|
|
243
|
+
geometry = document["geometry"]
|
|
244
|
+
appearance = document["appearance"]
|
|
245
|
+
reference = document["reference"]
|
|
246
|
+
target = geometry["target_alpha_bbox"]
|
|
247
|
+
tolerances = geometry["tolerances"]
|
|
248
|
+
safe = geometry["safe_rect"]
|
|
249
|
+
pivot = geometry["pivot"]
|
|
250
|
+
preserve = "; ".join(str(value).strip() for value in appearance["preserve"])
|
|
251
|
+
forbid = "; ".join(str(value).strip() for value in appearance["forbid"])
|
|
252
|
+
pixel_rule = " Use crisp nearest-neighbor pixel edges; do not resample or blur." if appearance.get("pixel_art", {}).get("enabled") else ""
|
|
253
|
+
sequence_clause = f" Sequence {document['sequence_id']} is immutable across this action." if document.get("sequence_id") else ""
|
|
254
|
+
contract = document.get("action_contract") if isinstance(document.get("action_contract"), dict) else {}
|
|
255
|
+
positive_clause = "; ".join(str(item).strip() for item in contract.get("positive_cues", []))
|
|
256
|
+
negative_clause = "; ".join(str(item).strip() for item in contract.get("negative_cues", []))
|
|
257
|
+
action_clause = f" Positive action cues: {positive_clause}. Negative action cues: {negative_clause}. Forbidden competing actions: {', '.join(str(item) for item in document.get('forbidden_action_ids', []))}." if positive_clause and negative_clause else ""
|
|
258
|
+
return (
|
|
259
|
+
f"MotionLoom Frame Generation Lock {document['lock_id']} for action {document['action_id']}.{sequence_clause} "
|
|
260
|
+
f"Use reference image {reference['image']} as the locked {reference['role']} with SHA-256 {reference['sha256']}. "
|
|
261
|
+
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. "
|
|
262
|
+
f"Pose: {frame['pose']} "
|
|
263
|
+
f"Canvas is exactly {canvas['width']} × {canvas['height']} pixels, {canvas['color_space']} with {canvas['alpha_mode']} alpha. "
|
|
264
|
+
f"Keep the subject centered near x={geometry['center_x']}; pivot={pivot['x']},{pivot['y']} ({pivot['space']}); footline={geometry['footline_px']} px. "
|
|
265
|
+
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. "
|
|
266
|
+
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. "
|
|
267
|
+
f"Preserve: {preserve}. Forbid: {forbid}.{pixel_rule} "
|
|
268
|
+
f"Do not mirror, crop from a shared canvas, silently change camera/scale, or resize the generated frame afterward.{action_clause} "
|
|
269
|
+
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."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def compose(document: dict[str, Any], root: Path, frame_id: str | None = None) -> dict[str, Any]:
|
|
274
|
+
validation = validate(document, root)
|
|
275
|
+
if not validation["ready"]:
|
|
276
|
+
return validation
|
|
277
|
+
frames = document["frames"] if frame_id is None else [find_frame(document, frame_id)]
|
|
278
|
+
if frame_id is not None and frames[0] is None:
|
|
279
|
+
return {
|
|
280
|
+
"contract": "frame_generation_lock",
|
|
281
|
+
"ready": False,
|
|
282
|
+
"errors": [issue("unknown_frame", f"frame_id not found in lock: {frame_id}", "frame_id")],
|
|
283
|
+
"warnings": [],
|
|
284
|
+
"metrics": validation["metrics"],
|
|
285
|
+
"approval": False,
|
|
286
|
+
}
|
|
287
|
+
postflight = document["postflight"]["frame_geometry"]
|
|
288
|
+
items = [
|
|
289
|
+
{
|
|
290
|
+
"frame_id": frame["frame_id"],
|
|
291
|
+
"output": frame["output"],
|
|
292
|
+
"instruction": compose_instruction(document, frame),
|
|
293
|
+
}
|
|
294
|
+
for frame in frames
|
|
295
|
+
if isinstance(frame, dict)
|
|
296
|
+
]
|
|
297
|
+
manifest_flag = f" --action-manifest {document['action_manifest']}" if document.get("action_manifest") else ""
|
|
298
|
+
return {
|
|
299
|
+
"contract": "frame_generation_lock",
|
|
300
|
+
"ready": True,
|
|
301
|
+
"lock_id": document["lock_id"],
|
|
302
|
+
"action_id": document["action_id"],
|
|
303
|
+
"sequence_id": document.get("sequence_id"),
|
|
304
|
+
"forbidden_action_ids": document.get("forbidden_action_ids", []),
|
|
305
|
+
"lock_sha256": validation["metrics"]["lock_sha256"],
|
|
306
|
+
"reference_sha256": validation["metrics"]["reference_sha256"],
|
|
307
|
+
"frames": items,
|
|
308
|
+
"next_gate": f"motionloom frame-set-preflight --input {postflight} --root {root}{manifest_flag} --json",
|
|
309
|
+
"approval": False,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def emit(result: dict[str, Any], as_json: bool) -> None:
|
|
314
|
+
if as_json:
|
|
315
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
316
|
+
return
|
|
317
|
+
if not result.get("ready"):
|
|
318
|
+
print("frame generation lock: FAIL")
|
|
319
|
+
for item in result.get("errors", []):
|
|
320
|
+
print(f"ERROR {item.get('code')}: {item.get('message')}")
|
|
321
|
+
return
|
|
322
|
+
if "frames" not in result:
|
|
323
|
+
print("frame generation lock: PASS")
|
|
324
|
+
return
|
|
325
|
+
for frame in result["frames"]:
|
|
326
|
+
print(frame["instruction"])
|
|
327
|
+
print()
|
|
328
|
+
print(f"Next gate: {result['next_gate']}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main(argv: list[str] | None = None) -> int:
|
|
332
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
333
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
334
|
+
for name in ("validate", "compose", "compose-all"):
|
|
335
|
+
command = sub.add_parser(name)
|
|
336
|
+
command.add_argument("--input", required=True, help="frame-generation-lock JSON")
|
|
337
|
+
command.add_argument("--root", default=".", help="project/asset root used to resolve paths")
|
|
338
|
+
command.add_argument("--json", action="store_true", dest="as_json")
|
|
339
|
+
if name == "compose":
|
|
340
|
+
command.add_argument("--frame-id", required=True)
|
|
341
|
+
args = parser.parse_args(argv)
|
|
342
|
+
document, load_errors = load_document(Path(args.input))
|
|
343
|
+
if load_errors or document is None:
|
|
344
|
+
result = {"contract": "frame_generation_lock", "ready": False, "errors": load_errors, "warnings": [], "metrics": {}, "approval": False}
|
|
345
|
+
else:
|
|
346
|
+
root = Path(args.root).resolve()
|
|
347
|
+
if args.command == "validate":
|
|
348
|
+
result = validate(document, root)
|
|
349
|
+
elif args.command == "compose":
|
|
350
|
+
result = compose(document, root, args.frame_id)
|
|
351
|
+
else:
|
|
352
|
+
result = compose(document, root)
|
|
353
|
+
emit(result, args.as_json)
|
|
354
|
+
return 0 if result.get("ready") else 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
raise SystemExit(main())
|