motionloom 2.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/SKILL.md +122 -0
- package/agent-card.json +161 -0
- package/assets/library/ATTRIBUTION.md +6 -0
- package/assets/library/README.md +20 -0
- package/assets/library/avatar-base.svg +19 -0
- package/assets/library/error-alert.json +1 -0
- package/assets/library/rive/ATTRIBUTION.md +12 -0
- package/assets/library/rive/state-machine-test.riv +0 -0
- package/assets/library/success-check.json +1 -0
- package/bin/motionloom.mjs +81 -0
- package/docs/BROWSER-REVIEW-E2E.md +78 -0
- package/docs/CATEGORIES.md +16 -0
- package/docs/CHECKLIST.md +31 -0
- package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
- package/docs/FRAMEWORK-SELECTION.md +26 -0
- package/docs/PROJECT-MANIFEST.md +37 -0
- package/docs/ROADMAP-INTELLIGENCE.md +224 -0
- package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
- package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
- package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
- package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
- package/docs/releases/1.5.0.md +25 -0
- package/docs/releases/1.6.0.md +27 -0
- package/docs/releases/1.7.0.md +23 -0
- package/docs/releases/1.8.0.md +23 -0
- package/docs/releases/1.9.0.md +21 -0
- package/docs/releases/2.0.0.md +21 -0
- package/docs/releases/npm-publish-from-workstation.md +88 -0
- package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
- package/examples/report-demo/REPORT.md +50 -0
- package/examples/report-demo/artifact-manifest.json +25 -0
- package/examples/report-demo/decision-log.jsonl +0 -0
- package/examples/report-demo/execution-report.json +70 -0
- package/examples/report-demo/handoff.json +22 -0
- package/examples/report-demo/issue-register.json +5 -0
- package/examples/report-demo/task.json +13 -0
- package/package.json +95 -0
- package/project-context.example.json +26 -0
- package/references/browser-review-contract.md +32 -0
- package/references/dotlottie-source-notes.md +21 -0
- package/references/intelligence-core.md +98 -0
- package/references/reporting-contract.md +38 -0
- package/references/runtime-capability.md +12 -0
- package/references/signed-attestation.md +31 -0
- package/schemas/artifact-manifest.schema.json +22 -0
- package/schemas/browser-review-candidate.schema.json +24 -0
- package/schemas/capability-registry.schema.json +61 -0
- package/schemas/continuity-report.schema.json +52 -0
- package/schemas/evidence-verifier-report.schema.json +35 -0
- package/schemas/execution-report.schema.json +35 -0
- package/schemas/fix-plan.schema.json +44 -0
- package/schemas/handoff.schema.json +19 -0
- package/schemas/motion-ir.schema.json +74 -0
- package/schemas/project-graph.schema.json +70 -0
- package/schemas/provenance.schema.json +76 -0
- package/schemas/runtime-evidence.schema.json +48 -0
- package/schemas/runtime-telemetry.schema.json +50 -0
- package/schemas/scene-manifest.schema.json +45 -0
- package/schemas/semantic-benchmark.schema.json +26 -0
- package/schemas/semantic-lint-report.schema.json +48 -0
- package/schemas/signed-attestation.schema.json +95 -0
- package/schemas/task.schema.json +39 -0
- package/schemas/trust-policy.schema.json +53 -0
- package/scripts/analyze.sh +13 -0
- package/scripts/attestation-keygen.py +63 -0
- package/scripts/attestation-verifier.py +178 -0
- package/scripts/attestation.py +288 -0
- package/scripts/capture-runtime-telemetry.sh +37 -0
- package/scripts/devlab.sh +77 -0
- package/scripts/eval-intelligence.py +377 -0
- package/scripts/evidence-verifier.py +222 -0
- package/scripts/fetch-library.sh +57 -0
- package/scripts/intelligence.py +1543 -0
- package/scripts/manifest.py +61 -0
- package/scripts/pr.sh +103 -0
- package/scripts/quality-gate.py +378 -0
- package/scripts/render-node.mjs +53 -0
- package/scripts/render.sh +37 -0
- package/scripts/report-contract.py +181 -0
- package/scripts/report.py +588 -0
- package/scripts/review-hook.py +199 -0
- package/scripts/runtime-adapters.mjs +187 -0
- package/scripts/skill-doctor.py +150 -0
- package/scripts/to-dotlottie.mjs +99 -0
- package/scripts/to-dotlottie.sh +25 -0
- package/scripts/validate-lottie.py +102 -0
- package/src/core/analyzer.py +226 -0
- package/src/core/snapshot.py +124 -0
- package/src/core/spec.py +240 -0
- package/src/output/browser-review-smoke/animation.json +57 -0
- package/src/output/browser-review-smoke/browser-review.json +21 -0
- package/src/output/browser-review-smoke/manifest.json +22 -0
- package/src/output/browser-review-smoke/motion-spec.json +28 -0
- package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
- package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
- package/src/rig/README.md +35 -0
- package/src/rig/cutout_rig.py +211 -0
- package/templates/framer-motion/ui-micro.tsx +50 -0
- package/templates/gsap/scroll-scene.js +54 -0
- package/templates/lottie/README.md +21 -0
- package/templates/lottie/react-component.tsx +82 -0
- package/templates/lottie/scaffold/animation.json +57 -0
- package/templates/lottie/scaffold/character-rig.svg +19 -0
- package/templates/lottie/vanilla.js +68 -0
- package/templates/rive/README.md +36 -0
- package/tests/evals/intelligence-cases.json +131 -0
- package/tests/scripts/run_tests.py +843 -0
- package/tests/scripts/test_attestation.py +172 -0
package/src/core/spec.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
spec.py — Step 2 of the pipeline: Motion Spec generation & validation.
|
|
4
|
+
|
|
5
|
+
A motion spec is the signed contract between the understanding step and the
|
|
6
|
+
generation step. It binds the animation to the project context, picks the
|
|
7
|
+
framework per the selection matrix, and validates that the requested values
|
|
8
|
+
stay inside the performance budget (file size, frame count, layer count).
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 src/core/spec.py generate <category> --duration 0.8 --loop \
|
|
12
|
+
--context project-context.json --output motion-spec.md
|
|
13
|
+
python3 src/core/spec.py validate motion-spec.md
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
FRAMEWORK_MATRIX = {
|
|
25
|
+
"ui-micro": ["framer-motion", "gsap"],
|
|
26
|
+
"loading": ["lottie", "framer-motion", "css"],
|
|
27
|
+
"hero-scene": ["lottie", "gsap", "threejs"],
|
|
28
|
+
"character-body": ["lottie", "spine"],
|
|
29
|
+
"icon-animation": ["lottie", "framer-motion", "css"],
|
|
30
|
+
"scroll-linked": ["gsap", "framer-motion"],
|
|
31
|
+
"data-viz": ["gsap", "framer-motion", "threejs"],
|
|
32
|
+
"3d-scene": ["threejs"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
EASING_CANON = {
|
|
36
|
+
"linear", "ease", "ease-in", "ease-out", "ease-in-out",
|
|
37
|
+
"ease-in-sine", "ease-out-sine", "ease-in-out-sine",
|
|
38
|
+
"ease-in-quad", "ease-out-quad", "ease-in-out-quad",
|
|
39
|
+
"ease-in-cubic", "ease-out-cubic", "ease-in-out-cubic",
|
|
40
|
+
"ease-in-expo", "ease-out-expo", "ease-in-out-expo",
|
|
41
|
+
"ease-in-back", "ease-out-back", "ease-in-out-back",
|
|
42
|
+
"spring", "anticipation", "overshoot",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
PERF_BUDGET = {
|
|
46
|
+
"max_file_kb": 300, # dotLottie target for UI; 1500 for hero
|
|
47
|
+
"max_layers": 80,
|
|
48
|
+
"max_duration_hero_s": 8,
|
|
49
|
+
"max_duration_ui_s": 2,
|
|
50
|
+
"default_fps": 60,
|
|
51
|
+
"rig_fps": 30,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_spec(args) -> dict:
|
|
56
|
+
context = {}
|
|
57
|
+
ctx_path = Path(args.context).resolve() if args.context else None
|
|
58
|
+
if ctx_path and ctx_path.exists():
|
|
59
|
+
try:
|
|
60
|
+
context = json.loads(ctx_path.read_text(encoding="utf-8"))
|
|
61
|
+
except json.JSONDecodeError as exc:
|
|
62
|
+
raise SystemExit(f"error: invalid context JSON: {ctx_path}: {exc}")
|
|
63
|
+
elif not args.allow_unbound:
|
|
64
|
+
raise SystemExit(f"error: context not found: {ctx_path}; run analyze first (or pass --allow-unbound explicitly)")
|
|
65
|
+
|
|
66
|
+
if args.category not in FRAMEWORK_MATRIX:
|
|
67
|
+
raise SystemExit(f"error: unknown category '{args.category}'; choose one of: {', '.join(FRAMEWORK_MATRIX)}")
|
|
68
|
+
duration = args.duration if args.duration is not None else _default_duration(args.category)
|
|
69
|
+
fps = args.fps or (PERF_BUDGET["rig_fps"] if args.category == "character-body" else PERF_BUDGET["default_fps"])
|
|
70
|
+
total_frames = round(duration * fps)
|
|
71
|
+
|
|
72
|
+
stack_pref = (context.get("stack") or {}).get("framework")
|
|
73
|
+
candidates = FRAMEWORK_MATRIX.get(args.category, ["lottie"])
|
|
74
|
+
framework = args.framework or (stack_pref if stack_pref in candidates else candidates[0])
|
|
75
|
+
|
|
76
|
+
easing = args.easing or context.get("motion_language", {}).get("recommendation") or "ease-in-out"
|
|
77
|
+
if easing not in EASING_CANON:
|
|
78
|
+
print(f"warning: easing '{easing}' not in canon; defaulting to ease-in-out", file=sys.stderr)
|
|
79
|
+
easing = "ease-in-out"
|
|
80
|
+
|
|
81
|
+
brand = context.get("brand", {})
|
|
82
|
+
manifest_motion = ((context.get("manifest_overrides") or {}).get("motion") or {})
|
|
83
|
+
spec = {
|
|
84
|
+
"version": "1.0",
|
|
85
|
+
"category": args.category,
|
|
86
|
+
"framework": framework,
|
|
87
|
+
"duration_s": duration,
|
|
88
|
+
"fps": fps,
|
|
89
|
+
"total_frames": total_frames,
|
|
90
|
+
"loop": _default_loop(args.category) if args.loop is None else args.loop,
|
|
91
|
+
"easing": easing,
|
|
92
|
+
"camera": args.camera or ("push-in" if args.category == "hero-scene" else None),
|
|
93
|
+
"theme": {
|
|
94
|
+
"primary": brand.get("primary"),
|
|
95
|
+
"accent": brand.get("accent"),
|
|
96
|
+
},
|
|
97
|
+
"performance": {
|
|
98
|
+
"max_file_kb": PERF_BUDGET["max_file_kb"],
|
|
99
|
+
"max_layers": PERF_BUDGET["max_layers"],
|
|
100
|
+
},
|
|
101
|
+
"source_binding": context.get("source_authority", ""),
|
|
102
|
+
"context_binding": {
|
|
103
|
+
"name": context.get("name"),
|
|
104
|
+
"project_root": context.get("project_root"),
|
|
105
|
+
"context_sha256": _sha256(ctx_path) if ctx_path and ctx_path.exists() else None,
|
|
106
|
+
},
|
|
107
|
+
"accessibility": {
|
|
108
|
+
"reduced_motion": manifest_motion.get("reduced_motion", "respect-prefers-reduced-motion"),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
if args.interactivity:
|
|
112
|
+
spec["interactivity"] = [i.strip() for i in args.interactivity.split(",")]
|
|
113
|
+
out = Path(args.output)
|
|
114
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
out.write_text(json.dumps(spec, indent=2) + "\n", encoding="utf-8")
|
|
116
|
+
print(f"motion-spec.json written -> {out}")
|
|
117
|
+
return spec
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _default_duration(category: str) -> float:
|
|
121
|
+
# CLI-first import: works when run directly without PYTHONPATH
|
|
122
|
+
for extra in (".", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))):
|
|
123
|
+
if extra not in sys.path:
|
|
124
|
+
sys.path.insert(0, extra)
|
|
125
|
+
return _load_categories().get(category, {}).get("default_duration_s", 1.0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _default_loop(category: str) -> bool:
|
|
129
|
+
return bool(_load_categories().get(category, {}).get("loop", False))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _load_categories() -> dict:
|
|
133
|
+
try:
|
|
134
|
+
from core.analyzer import CATEGORIES # noqa
|
|
135
|
+
return CATEGORIES
|
|
136
|
+
except ModuleNotFoundError:
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sha256(path: Path) -> str:
|
|
141
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_spec(path: str, context_path: str | None = None) -> list:
|
|
145
|
+
issues = []
|
|
146
|
+
try:
|
|
147
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
148
|
+
spec = json.loads(text) if text.strip().startswith("{") else _parse_md_spec(text)
|
|
149
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
150
|
+
return [f"invalid motion spec: {exc}"]
|
|
151
|
+
if not isinstance(spec, dict):
|
|
152
|
+
return ["motion spec root must be a JSON object"]
|
|
153
|
+
category = spec.get("category")
|
|
154
|
+
framework = spec.get("framework")
|
|
155
|
+
if category not in FRAMEWORK_MATRIX:
|
|
156
|
+
issues.append(f"unknown category '{category}'")
|
|
157
|
+
elif framework not in FRAMEWORK_MATRIX[category]:
|
|
158
|
+
issues.append(f"framework '{framework}' is not allowed for category '{category}'")
|
|
159
|
+
duration = spec.get("duration_s")
|
|
160
|
+
fps = spec.get("fps")
|
|
161
|
+
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
|
162
|
+
issues.append("duration_s must be a number")
|
|
163
|
+
duration = 0
|
|
164
|
+
if not isinstance(fps, (int, float)) or isinstance(fps, bool):
|
|
165
|
+
issues.append("fps must be a number")
|
|
166
|
+
fps = 0
|
|
167
|
+
if duration <= 0:
|
|
168
|
+
issues.append("duration_s must be greater than zero")
|
|
169
|
+
if fps <= 0:
|
|
170
|
+
issues.append("fps must be greater than zero")
|
|
171
|
+
if duration > PERF_BUDGET["max_duration_ui_s"] and category not in ("hero-scene", "character-body", "3d-scene"):
|
|
172
|
+
issues.append(f"duration {duration}s exceeds {PERF_BUDGET['max_duration_ui_s']}s budget for {category}")
|
|
173
|
+
if fps not in (30, 60, 120):
|
|
174
|
+
issues.append(f"fps {fps} is non-standard; prefer 30/60/120")
|
|
175
|
+
expected_frames = round(duration * fps)
|
|
176
|
+
if spec.get("total_frames") != expected_frames:
|
|
177
|
+
issues.append(f"total_frames {spec.get('total_frames')} does not equal duration_s × fps ({expected_frames})")
|
|
178
|
+
easing = spec.get("easing")
|
|
179
|
+
if easing not in EASING_CANON:
|
|
180
|
+
issues.append(f"easing '{easing}' not in canonical list")
|
|
181
|
+
if not spec.get("theme", {}).get("primary"):
|
|
182
|
+
issues.append("no brand primary color bound — run analyzer first")
|
|
183
|
+
binding = spec.get("context_binding") or {}
|
|
184
|
+
if not binding.get("name") or not binding.get("project_root") or not binding.get("context_sha256"):
|
|
185
|
+
issues.append("missing context_binding — spec is not bound to a project context")
|
|
186
|
+
if context_path:
|
|
187
|
+
context_file = Path(context_path)
|
|
188
|
+
if not context_file.exists():
|
|
189
|
+
issues.append(f"context file not found: {context_file}")
|
|
190
|
+
elif binding.get("context_sha256") != _sha256(context_file):
|
|
191
|
+
issues.append("context hash mismatch — regenerate motion spec after project analysis changes")
|
|
192
|
+
return issues
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_md_spec(text: str) -> dict:
|
|
196
|
+
def grab(key):
|
|
197
|
+
m = re.search(rf"-\s*\*?{key}\*?\s*[:=]\s*(.+)", text)
|
|
198
|
+
return m.group(1).strip() if m else None
|
|
199
|
+
return {
|
|
200
|
+
"category": grab("category") or "ui-micro",
|
|
201
|
+
"framework": grab("framework") or "lottie",
|
|
202
|
+
"duration_s": float(grab("duration") or 1.0),
|
|
203
|
+
"fps": int(grab("fps") or 60),
|
|
204
|
+
"loop": "true" in (grab("loop") or "false"),
|
|
205
|
+
"easing": grab("easing") or "ease-in-out",
|
|
206
|
+
"theme": {"primary": grab("primary")},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main():
|
|
211
|
+
parser = argparse.ArgumentParser()
|
|
212
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
213
|
+
g = sub.add_parser("generate")
|
|
214
|
+
g.add_argument("category")
|
|
215
|
+
g.add_argument("--framework")
|
|
216
|
+
g.add_argument("--duration", type=float)
|
|
217
|
+
g.add_argument("--fps", type=int)
|
|
218
|
+
g.add_argument("--loop", action="store_true")
|
|
219
|
+
g.add_argument("--no-loop", dest="loop", action="store_false")
|
|
220
|
+
g.add_argument("--easing")
|
|
221
|
+
g.add_argument("--camera")
|
|
222
|
+
g.add_argument("--interactivity")
|
|
223
|
+
g.add_argument("--context", default="project-context.json")
|
|
224
|
+
g.add_argument("--allow-unbound", action="store_true", help="allow generation without project-context.json (not accepted by quality gate)")
|
|
225
|
+
g.add_argument("--output", default="motion-spec.json")
|
|
226
|
+
g.set_defaults(loop=None)
|
|
227
|
+
v = sub.add_parser("validate")
|
|
228
|
+
v.add_argument("path")
|
|
229
|
+
v.add_argument("--context")
|
|
230
|
+
args = parser.parse_args()
|
|
231
|
+
if args.cmd == "generate":
|
|
232
|
+
generate_spec(args)
|
|
233
|
+
elif args.cmd == "validate":
|
|
234
|
+
issues = validate_spec(args.path, args.context)
|
|
235
|
+
print("PASS — no issues" if not issues else "ISSUES:\n" + "\n".join(f"- {i}" for i in issues))
|
|
236
|
+
sys.exit(1 if issues else 0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v": "5.12.0",
|
|
3
|
+
"fr": 60,
|
|
4
|
+
"ip": 0,
|
|
5
|
+
"op": 60,
|
|
6
|
+
"w": 512,
|
|
7
|
+
"h": 512,
|
|
8
|
+
"nm": "generated-scene",
|
|
9
|
+
"ddd": 0,
|
|
10
|
+
"assets": [],
|
|
11
|
+
"layers": [
|
|
12
|
+
{
|
|
13
|
+
"ddd": 0,
|
|
14
|
+
"ind": 1,
|
|
15
|
+
"ty": 4,
|
|
16
|
+
"nm": "shape-layer",
|
|
17
|
+
"sr": 1,
|
|
18
|
+
"ks": {
|
|
19
|
+
"o": { "a": 0, "k": 100 },
|
|
20
|
+
"r": { "a": 0, "k": 0 },
|
|
21
|
+
"p": { "a": 0, "k": [256, 256, 0] },
|
|
22
|
+
"a": { "a": 0, "k": [0, 0, 0] },
|
|
23
|
+
"s": {
|
|
24
|
+
"a": 1,
|
|
25
|
+
"k": [
|
|
26
|
+
{ "t": 0, "s": [80, 80, 100], "i": { "x": [0.4], "y": [1] }, "o": { "x": [0.6], "y": [0] } },
|
|
27
|
+
{ "t": 30, "s": [105, 105, 100], "i": { "x": [0.4], "y": [1] }, "o": { "x": [0.6], "y": [0] } },
|
|
28
|
+
{ "t": 60, "s": [100, 100, 100] }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"ao": 0,
|
|
33
|
+
"shapes": [
|
|
34
|
+
{
|
|
35
|
+
"ty": "gr",
|
|
36
|
+
"it": [
|
|
37
|
+
{ "ty": "el", "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [120, 120] } },
|
|
38
|
+
{
|
|
39
|
+
"ty": "fl",
|
|
40
|
+
"c": { "a": 0, "k": [0.145, 0.388, 0.922, 1] },
|
|
41
|
+
"o": { "a": 0, "k": 100 },
|
|
42
|
+
"r": 1
|
|
43
|
+
},
|
|
44
|
+
{ "ty": "tr", "p": { "a": 0, "k": [0, 0] }, "a": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"ip": 0,
|
|
49
|
+
"op": 60,
|
|
50
|
+
"st": 0
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"markers": [
|
|
54
|
+
{ "cm": "loop-start", "tm": 0, "dr": 30 },
|
|
55
|
+
{ "cm": "loop-end", "tm": 30, "dr": 30 }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"candidate_id": "88a2f2f18ba45a07f56e",
|
|
4
|
+
"task_id": "professional-review-e2e",
|
|
5
|
+
"scene": "browser-review-smoke",
|
|
6
|
+
"url": "https://3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer/?scene=browser-review-smoke&task_id=professional-review-e2e&candidate_id=88a2f2f18ba45a07f56e&artifact_base=https%3A%2F%2F3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer%2Fscenes%2Fbrowser-review-smoke&task_base=https%3A%2F%2F3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer%2Ftasks%2Fprofessional-review-e2e",
|
|
7
|
+
"status": "approved",
|
|
8
|
+
"context_sha256": "cbbd43a53a0ddd5cf5452df5f6d521f06829465bd58c6d764a59d501ed1710fa",
|
|
9
|
+
"source_sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a",
|
|
10
|
+
"runtime": "lottie",
|
|
11
|
+
"checkpoints": [
|
|
12
|
+
0,
|
|
13
|
+
50,
|
|
14
|
+
100
|
|
15
|
+
],
|
|
16
|
+
"review_artifact": "review.json",
|
|
17
|
+
"requires_user_approval": true,
|
|
18
|
+
"prepared_at": "2026-08-12T17:42:31Z",
|
|
19
|
+
"expires_at": "2026-08-13T17:42:31Z",
|
|
20
|
+
"reviewed_at": "2026-08-12T17:48:54Z"
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Browser Review Smoke",
|
|
3
|
+
"description": "Runtime candidate used to verify the mandatory internal browser review handoff.",
|
|
4
|
+
"framework": "lottie",
|
|
5
|
+
"category": "loading",
|
|
6
|
+
"file": "animation.json",
|
|
7
|
+
"source_binding": {
|
|
8
|
+
"kind": "fixture",
|
|
9
|
+
"source_path": "animation.json",
|
|
10
|
+
"source_url": "https://github.com/lenhonbp/MotionLoom/tree/main/src/output/browser-review-smoke",
|
|
11
|
+
"authority": "MotionLoom browser-review smoke fixture",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"attribution": "Generated fixture retained for deterministic runtime and browser-review tests.",
|
|
14
|
+
"sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a"
|
|
15
|
+
},
|
|
16
|
+
"checks": [
|
|
17
|
+
{"id": "runtime", "label": "Runtime render evidence exists", "detail": "Frames 0, 50 and 100 are produced by the Lottie runtime.", "pass": true},
|
|
18
|
+
{"id": "timing", "label": "Timing matches motion spec", "detail": "Duration and FPS are bound to motion-spec.json.", "pass": true},
|
|
19
|
+
{"id": "reduced-motion", "label": "Reduced-motion policy is documented", "detail": "The motion spec contains the accessibility policy.", "pass": true},
|
|
20
|
+
{"id": "brand", "label": "Project context is bound", "detail": "Candidate context hash matches the generated spec.", "pass": true}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"category": "loading",
|
|
4
|
+
"framework": "lottie",
|
|
5
|
+
"duration_s": 1.0,
|
|
6
|
+
"fps": 60,
|
|
7
|
+
"total_frames": 60,
|
|
8
|
+
"loop": false,
|
|
9
|
+
"easing": "ease-out",
|
|
10
|
+
"camera": null,
|
|
11
|
+
"theme": {
|
|
12
|
+
"primary": "#2563EB",
|
|
13
|
+
"accent": "#F59E0B"
|
|
14
|
+
},
|
|
15
|
+
"performance": {
|
|
16
|
+
"max_file_kb": 300,
|
|
17
|
+
"max_layers": 80
|
|
18
|
+
},
|
|
19
|
+
"source_binding": "project-manifest.json then assets/library/, never invented geometry",
|
|
20
|
+
"context_binding": {
|
|
21
|
+
"name": "browser-review-fixture",
|
|
22
|
+
"project_root": "/tmp/animation-review-host",
|
|
23
|
+
"context_sha256": "cbbd43a53a0ddd5cf5452df5f6d521f06829465bd58c6d764a59d501ed1710fa"
|
|
24
|
+
},
|
|
25
|
+
"accessibility": {
|
|
26
|
+
"reduced_motion": "respect-prefers-reduced-motion"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Cutout Body Rigging (`src/rig/`)
|
|
2
|
+
|
|
3
|
+
This module turns flat vector art into an animatable character body using the cutout rig technique: each limb is a separate layer pivoted on an explicit joint, and motion is expressed as per-bone rotation keyframes. It is the same principle used by Spine and Duik, implemented portably in pure SVG + JSON keyframes so it plays in Lottie, GSAP, Framer Motion, or CSS.
|
|
4
|
+
|
|
5
|
+
## Bone hierarchy
|
|
6
|
+
|
|
7
|
+
Bones rotate in strict parent-first order: `hip → spine → chest → head`, shoulders branch to upper-arm → forearm → hand, and hips to thigh → shin → foot. The engine (`cutout_rig.py`) enforces this order when building the SVG `<g>` tree — a child never appears before its parent.
|
|
8
|
+
|
|
9
|
+
## Conventions
|
|
10
|
+
|
|
11
|
+
Each bone group carries two markers: `data-bone="<name>"` (used by the player to target rotations) and an inline `transform="rotate(...)"` so the pose is readable directly in the file. The `DEFAULT_POSE` dictionary in `cutout_rig.py` is the T-pose baseline; every clip is additive to it.
|
|
12
|
+
|
|
13
|
+
## Building a rig
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
python3 src/rig/cutout_rig.py build \
|
|
17
|
+
--input assets/library/character-flat.svg \
|
|
18
|
+
--output rigged-avatar.svg \
|
|
19
|
+
--pivot-x 0 --pivot-y 0
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If no authoritative art is provided, the engine emits a default geometric avatar that the designer must replace — never ship the placeholder as final art.
|
|
23
|
+
|
|
24
|
+
## Clips and poses
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
python3 src/rig/cutout_rig.py pose rigged-avatar.svg --pose walk \
|
|
28
|
+
--duration 1.5 --fps 30 --out idle-clip.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Built-in clips: `idle`, `walk`, `wave`, `nod`, `bounce`. Custom clips are plain JSON maps of `bone → [start_deg, end_deg]`. Interpolation uses smoothstep ease-in-out; set `loop: true` on the player for seamless cycles (`idle`, `walk`).
|
|
32
|
+
|
|
33
|
+
## Exporting to Lottie
|
|
34
|
+
|
|
35
|
+
Feed the rigged SVG plus the clip JSON to the Lottie template in `templates/lottie/` — the template wraps the bone tree in a dotLottie with a theme slot for the body color so the brand analyzer can recolor the character at runtime without regenerating the file.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cutout_rig.py — Body rigging engine for cutout character animation.
|
|
4
|
+
|
|
5
|
+
Builds a skeletal hierarchy from an authoritative flat SVG and emits
|
|
6
|
+
frame-interpolated pose keyframes suitable for
|
|
7
|
+
Lottie export or direct GSAP/CSS playback.
|
|
8
|
+
|
|
9
|
+
Hierarchy convention (parent-first rotation order):
|
|
10
|
+
root (hip)
|
|
11
|
+
spine -> chest -> head
|
|
12
|
+
L-shoulder -> L-upper-arm -> L-forearm -> L-hand
|
|
13
|
+
R-shoulder -> R-upper-arm -> R-forearm -> R-hand
|
|
14
|
+
L-hip -> L-thigh -> L-shin -> L-foot
|
|
15
|
+
R-hip -> R-thigh -> R-shin -> R-foot
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 src/rig/cutout_rig.py build --input assets/library/avatar-base.svg --output rigged-avatar.svg
|
|
19
|
+
python3 src/rig/cutout_rig.py pose rigged-avatar.svg --pose idle --duration 1.5 --fps 30 --out idle.json
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
import re
|
|
26
|
+
import uuid
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
BONES = [
|
|
30
|
+
"hip", "spine", "chest", "head",
|
|
31
|
+
"l_shoulder", "l_upper_arm", "l_forearm", "l_hand",
|
|
32
|
+
"r_shoulder", "r_upper_arm", "r_forearm", "r_hand",
|
|
33
|
+
"l_hip", "l_thigh", "l_shin", "l_foot",
|
|
34
|
+
"r_hip", "r_thigh", "r_shin", "r_foot",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Default pose: degrees relative to parent (T-pose-ish standing)
|
|
38
|
+
DEFAULT_POSE = {
|
|
39
|
+
"hip": 0, "spine": 0, "chest": 0, "head": 0,
|
|
40
|
+
"l_shoulder": 10, "l_upper_arm": -8, "l_forearm": -12, "l_hand": 0,
|
|
41
|
+
"r_shoulder": -10, "r_upper_arm": 8, "r_forearm": 12, "r_hand": 0,
|
|
42
|
+
"l_hip": 0, "l_thigh": 2, "l_shin": -1, "l_foot": 0,
|
|
43
|
+
"r_hip": 0, "r_thigh": -2, "r_shin": 1, "r_foot": 0,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Preset motion clips: per-bone (start, end) offsets in degrees, additive to DEFAULT_POSE
|
|
47
|
+
CLIPS = {
|
|
48
|
+
"idle": {
|
|
49
|
+
"head": (-3, 3), "chest": (-1, 1), "l_upper_arm": (4, 4),
|
|
50
|
+
"r_upper_arm": (-4, -4), "spine": (-1, 1),
|
|
51
|
+
},
|
|
52
|
+
"walk": {
|
|
53
|
+
"l_thigh": (18, -18), "r_thigh": (-18, 18),
|
|
54
|
+
"l_shin": (-22, 10), "r_shin": (10, -22),
|
|
55
|
+
"l_upper_arm": (-24, 24), "r_upper_arm": (24, -24),
|
|
56
|
+
"spine": (-3, 3), "head": (2, -2),
|
|
57
|
+
},
|
|
58
|
+
"wave": {
|
|
59
|
+
"r_upper_arm": (-110, -110), "r_forearm": (10, 50),
|
|
60
|
+
"r_hand": (0, 25), "chest": (0, 4), "head": (0, -6),
|
|
61
|
+
},
|
|
62
|
+
"nod": {"head": (0, 14)},
|
|
63
|
+
"bounce": {"hip": (0, 6), "spine": (-2, 2), "l_upper_arm": (6, -6), "r_upper_arm": (-6, 6)},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
EASE_IN_OUT = lambda t: t * t * (3 - 2 * t) # smoothstep
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def svg_group(bone: str, children_svg: str, pivot: str, shape: str) -> str:
|
|
70
|
+
"""Wrap a bone in a <g> with data-bone attribute and transform-origin pivot."""
|
|
71
|
+
return (
|
|
72
|
+
f'<g data-bone="{bone}" style="transform-origin:{pivot}" '
|
|
73
|
+
f'transform="rotate({DEFAULT_POSE[bone]:.1f} 0 0)">{shape}{children_svg}</g>'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_rig(args) -> str:
|
|
78
|
+
if not args.input:
|
|
79
|
+
if not args.allow_placeholder:
|
|
80
|
+
raise SystemExit("error: --input authoritative SVG is required; use --allow-placeholder only for local prototyping")
|
|
81
|
+
flat = _default_avatar_svg()
|
|
82
|
+
elif not Path(args.input).exists():
|
|
83
|
+
raise SystemExit(f"error: SVG asset not found: {args.input}")
|
|
84
|
+
else:
|
|
85
|
+
flat = Path(args.input).read_text(encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
# Prefer explicit data-part names; positional assignment is only a
|
|
88
|
+
# compatibility fallback for legacy source art without names.
|
|
89
|
+
parts = _split_svg_parts(flat)
|
|
90
|
+
assigned = {name: shape for name, shape in parts if name}
|
|
91
|
+
if not assigned:
|
|
92
|
+
assigned = dict(zip(BONES, [shape for _, shape in parts]))
|
|
93
|
+
|
|
94
|
+
def hierarchy(bone: str) -> str:
|
|
95
|
+
kids = {
|
|
96
|
+
"hip": ["spine", "l_hip", "r_hip"],
|
|
97
|
+
"spine": ["chest"],
|
|
98
|
+
"chest": ["head", "l_shoulder", "r_shoulder"],
|
|
99
|
+
"l_shoulder": ["l_upper_arm"], "l_upper_arm": ["l_forearm"], "l_forearm": ["l_hand"],
|
|
100
|
+
"r_shoulder": ["r_upper_arm"], "r_upper_arm": ["r_forearm"], "r_forearm": ["r_hand"],
|
|
101
|
+
"l_hip": ["l_thigh"], "l_thigh": ["l_shin"], "l_shin": ["l_foot"],
|
|
102
|
+
"r_hip": ["r_thigh"], "r_thigh": ["r_shin"], "r_shin": ["r_foot"],
|
|
103
|
+
}
|
|
104
|
+
children = "".join(hierarchy(c) for c in kids.get(bone, []))
|
|
105
|
+
shape = assigned.get(bone, "")
|
|
106
|
+
pivot = f"{args.pivot_x}px {args.pivot_y}px"
|
|
107
|
+
return svg_group(bone, children, pivot, shape)
|
|
108
|
+
|
|
109
|
+
inner = hierarchy("hip")
|
|
110
|
+
doc = flat if "<g data-bone=" in flat else f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {args.width} {args.height}">
|
|
111
|
+
<g id="rig" data-rig="cutout-v1">{inner}</g></svg>'''
|
|
112
|
+
out = Path(args.output)
|
|
113
|
+
out.write_text(doc, encoding="utf-8")
|
|
114
|
+
print(f"rigged avatar written -> {out} ({len(BONES)} bones)")
|
|
115
|
+
return str(out)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _split_svg_parts(svg_text: str) -> list:
|
|
119
|
+
"""Extract `(data-part, SVG fragment)` without changing the source geometry."""
|
|
120
|
+
named = []
|
|
121
|
+
# Covers self-closing primitives and named groups. It intentionally does
|
|
122
|
+
# not claim arbitrary unlabelled nested geometry as a body part.
|
|
123
|
+
pattern = re.compile(
|
|
124
|
+
r'(<g\b[^>]*data-part=["\']([^"\']+)["\'][^>]*>.*?</g>|'
|
|
125
|
+
r'<(?:path|circle|ellipse|rect|polygon)\b[^>]*data-part=["\']([^"\']+)["\'][^>]*/>)',
|
|
126
|
+
re.S,
|
|
127
|
+
)
|
|
128
|
+
for match in pattern.finditer(svg_text):
|
|
129
|
+
named.append((match.group(2) or match.group(3), match.group(1)))
|
|
130
|
+
if named:
|
|
131
|
+
return named
|
|
132
|
+
legacy = re.findall(
|
|
133
|
+
r'(<g\b[^>]*>.*?</g>|<(?:path|circle|ellipse|rect|polygon)\b[^>]*/>)',
|
|
134
|
+
svg_text,
|
|
135
|
+
re.S,
|
|
136
|
+
)
|
|
137
|
+
return [(None, fragment) for fragment in legacy]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _default_avatar_svg() -> str:
|
|
141
|
+
return f"""<!-- cutout avatar base — replace with authoritative art from assets/library/ -->
|
|
142
|
+
<circle data-part="head" cx="250" cy="90" r="42" fill="#2d2d3a"/>
|
|
143
|
+
<rect data-part="chest" x="208" y="140" width="84" height="96" rx="22" fill="#2d2d3a"/>
|
|
144
|
+
<rect data-part="l_upper_arm" x="178" y="148" width="26" height="78" rx="13" fill="#45455a"/>
|
|
145
|
+
<rect data-part="r_upper_arm" x="296" y="148" width="26" height="78" rx="13" fill="#45455a"/>
|
|
146
|
+
<rect data-part="l_forearm" x="176" y="228" width="22" height="70" rx="11" fill="#45455a"/>
|
|
147
|
+
<rect data-part="r_forearm" x="302" y="228" width="22" height="70" rx="11" fill="#45455a"/>
|
|
148
|
+
<rect data-part="l_thigh" x="216" y="238" width="30" height="84" rx="15" fill="#37374a"/>
|
|
149
|
+
<rect data-part="r_thigh" x="254" y="238" width="30" height="84" rx="15" fill="#37374a"/>
|
|
150
|
+
<rect data-part="l_shin" x="218" y="324" width="26" height="80" rx="13" fill="#37374a"/>
|
|
151
|
+
<rect data-part="r_shin" x="256" y="324" width="26" height="80" rx="13" fill="#37374a"/>
|
|
152
|
+
<ellipse data-part="l_foot" cx="231" cy="408" rx="24" ry="10" fill="#1f1f2e"/>
|
|
153
|
+
<ellipse data-part="r_foot" cx="269" cy="408" rx="24" ry="10" fill="#1f1f2e"/>"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pose(args) -> dict:
|
|
157
|
+
rig = Path(args.rig).read_text(encoding="utf-8")
|
|
158
|
+
clip = CLIPS.get(args.pose) or _parse_clip_file(args.pose)
|
|
159
|
+
fps = args.fps or 30
|
|
160
|
+
duration = args.duration or 1.5
|
|
161
|
+
frames = max(2, round(duration * fps))
|
|
162
|
+
|
|
163
|
+
keyframes = []
|
|
164
|
+
for i in range(frames):
|
|
165
|
+
t = EASE_IN_OUT(i / (frames - 1)) if frames > 1 else 0
|
|
166
|
+
# ping-pong: forward half then back
|
|
167
|
+
tt = 1 - abs(1 - 2 * (i / max(1, frames - 1)))
|
|
168
|
+
pose_frame = dict(DEFAULT_POSE)
|
|
169
|
+
for bone, (a, b) in clip.items():
|
|
170
|
+
pose_frame[bone] = DEFAULT_POSE.get(bone, 0) + (a + (b - a) * tt)
|
|
171
|
+
keyframes.append({"frame": i, "time_s": round(i / fps, 3), "angles": pose_frame})
|
|
172
|
+
|
|
173
|
+
out = Path(args.out)
|
|
174
|
+
out.write_text(json.dumps({
|
|
175
|
+
"rig": str(args.rig), "clip": args.pose, "fps": fps,
|
|
176
|
+
"duration_s": duration, "frames": len(keyframes), "keyframes": keyframes,
|
|
177
|
+
}, indent=2) + "\n", encoding="utf-8")
|
|
178
|
+
print(f"pose clip written -> {out} ({len(keyframes)} frames)")
|
|
179
|
+
return {"file": str(out), "frames": len(keyframes)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_clip_file(path: str) -> dict:
|
|
183
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
parser = argparse.ArgumentParser()
|
|
188
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
189
|
+
b = sub.add_parser("build")
|
|
190
|
+
b.add_argument("--input")
|
|
191
|
+
b.add_argument("--output", default="rigged-avatar.svg")
|
|
192
|
+
b.add_argument("--pivot-x", default=0)
|
|
193
|
+
b.add_argument("--pivot-y", default=0)
|
|
194
|
+
b.add_argument("--width", default=500)
|
|
195
|
+
b.add_argument("--height", default=450)
|
|
196
|
+
p = sub.add_parser("pose")
|
|
197
|
+
p.add_argument("rig")
|
|
198
|
+
p.add_argument("--pose", required=True)
|
|
199
|
+
p.add_argument("--duration", type=float)
|
|
200
|
+
p.add_argument("--fps", type=int)
|
|
201
|
+
p.add_argument("--out", default="clip.json")
|
|
202
|
+
b.add_argument("--allow-placeholder", action="store_true", help="allow generated geometric avatar for local prototyping only")
|
|
203
|
+
args = parser.parse_args()
|
|
204
|
+
if args.cmd == "build":
|
|
205
|
+
build_rig(args)
|
|
206
|
+
elif args.cmd == "pose":
|
|
207
|
+
pose(args)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|