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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
validate-lottie.py — Acceptance gate for generated Lottie files.
|
|
4
|
+
|
|
5
|
+
Checks the Bodymovin version header, frame count against the signed spec,
|
|
6
|
+
layer count against the performance budget, and (optionally) slot names
|
|
7
|
+
against the brand spec. Exit 0 = accepted, exit 1 = rejected with reasons.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/validate-lottie.py <animation.json|animation.lottie> \
|
|
11
|
+
--spec motion-spec.json --max-layers 80
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import zipfile
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_lottie(path: Path, animation_id: str | None = None) -> dict:
|
|
22
|
+
if path.suffix == ".lottie":
|
|
23
|
+
with zipfile.ZipFile(path) as zf:
|
|
24
|
+
if "manifest.json" not in zf.namelist():
|
|
25
|
+
raise ValueError("dotLottie manifest.json is required")
|
|
26
|
+
manifest = json.loads(zf.read("manifest.json"))
|
|
27
|
+
entries = manifest.get("animations")
|
|
28
|
+
if not isinstance(entries, list) or not entries:
|
|
29
|
+
raise ValueError("dotLottie manifest must declare at least one animation")
|
|
30
|
+
chosen = animation_id or ((manifest.get("initial") or {}).get("animation"))
|
|
31
|
+
entry = next((item for item in entries if item.get("id") == chosen), None) if chosen else entries[0]
|
|
32
|
+
if entry is None:
|
|
33
|
+
raise ValueError(f"animation id not found in manifest: {chosen}")
|
|
34
|
+
ident = str(entry.get("id", ""))
|
|
35
|
+
candidates = [f"a/{ident}", f"a/{ident}.json", ident, f"{ident}.json"]
|
|
36
|
+
name = next((candidate for candidate in candidates if candidate in zf.namelist()), None)
|
|
37
|
+
if not name:
|
|
38
|
+
raise ValueError(f"animation '{ident}' has no JSON payload under a/")
|
|
39
|
+
return json.loads(zf.read(name))
|
|
40
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate(path: Path, spec: dict | None, max_layers: int, animation_id: str | None = None) -> list:
|
|
44
|
+
issues = []
|
|
45
|
+
try:
|
|
46
|
+
doc = load_lottie(path, animation_id or ((spec or {}).get("animation_id")))
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return [f"cannot parse file: {e}"]
|
|
49
|
+
|
|
50
|
+
if not doc.get("v"):
|
|
51
|
+
issues.append("missing Bodymovin version header (v)")
|
|
52
|
+
op = doc.get("op")
|
|
53
|
+
fr = doc.get("fr")
|
|
54
|
+
if op is None or fr is None:
|
|
55
|
+
issues.append("missing frame metadata (fr/op)")
|
|
56
|
+
elif spec and abs(op - spec.get("total_frames", op)) > 3:
|
|
57
|
+
issues.append(f"frame count {op} deviates from spec total_frames {spec.get('total_frames')}")
|
|
58
|
+
|
|
59
|
+
layers = len(doc.get("layers", []))
|
|
60
|
+
if layers > max_layers:
|
|
61
|
+
issues.append(f"layer count {layers} exceeds budget {max_layers}")
|
|
62
|
+
|
|
63
|
+
kb = path.stat().st_size / 1024
|
|
64
|
+
budget_kb = 1500 if (spec and spec.get("category") == "hero-scene") else 300
|
|
65
|
+
if kb > budget_kb:
|
|
66
|
+
issues.append(f"file size {kb:.0f}KB exceeds budget {budget_kb}KB")
|
|
67
|
+
|
|
68
|
+
if spec:
|
|
69
|
+
expected_slots = set(spec.get("slots", []))
|
|
70
|
+
found_slots = set()
|
|
71
|
+
for slot in (doc.get("themes") or {}).get("slots", []):
|
|
72
|
+
found_slots.add(slot.get("id") or slot.get("p"))
|
|
73
|
+
missing = expected_slots - found_slots
|
|
74
|
+
if missing:
|
|
75
|
+
issues.append(f"brand slots missing: {sorted(missing)}")
|
|
76
|
+
|
|
77
|
+
return issues
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main():
|
|
81
|
+
parser = argparse.ArgumentParser()
|
|
82
|
+
parser.add_argument("path")
|
|
83
|
+
parser.add_argument("--spec")
|
|
84
|
+
parser.add_argument("--max-layers", type=int, default=80)
|
|
85
|
+
parser.add_argument("--animation-id")
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
|
|
88
|
+
spec = None
|
|
89
|
+
if args.spec:
|
|
90
|
+
spec = json.loads(Path(args.spec).read_text(encoding="utf-8"))
|
|
91
|
+
|
|
92
|
+
issues = validate(Path(args.path), spec, args.max_layers, args.animation_id)
|
|
93
|
+
if issues:
|
|
94
|
+
print("REJECTED:")
|
|
95
|
+
for i in issues:
|
|
96
|
+
print(f" - {i}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
print("ACCEPTED — lottie passes validation")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
analyzer.py — Step 1 of the MotionLoom pipeline: Project Understanding.
|
|
4
|
+
|
|
5
|
+
Reads a project repository (package.json, tailwind config, design tokens,
|
|
6
|
+
existing animation files, README) and emits `project-context.json`:
|
|
7
|
+
the single source of truth that all later steps (plan, source, generate)
|
|
8
|
+
must bind to. The agent is required to ground every generated animation
|
|
9
|
+
in this context, never in assumptions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Category taxonomy — maps a user request to an animation class
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
CATEGORIES = {
|
|
25
|
+
"ui-micro": {
|
|
26
|
+
"description": "Buttons, toggles, loaders, hover/focus states, feedback toasts",
|
|
27
|
+
"framework": "framer-motion",
|
|
28
|
+
"default_duration_s": 0.3,
|
|
29
|
+
"fps": 60,
|
|
30
|
+
"loop": False,
|
|
31
|
+
},
|
|
32
|
+
"loading": {
|
|
33
|
+
"description": "Spinners, skeletons, progress indicators",
|
|
34
|
+
"framework": "lottie",
|
|
35
|
+
"default_duration_s": 1.0,
|
|
36
|
+
"fps": 60,
|
|
37
|
+
"loop": True,
|
|
38
|
+
},
|
|
39
|
+
"hero-scene": {
|
|
40
|
+
"description": "Full-viewport marketing scenes with camera motion",
|
|
41
|
+
"framework": "lottie",
|
|
42
|
+
"default_duration_s": 3.5,
|
|
43
|
+
"fps": 60,
|
|
44
|
+
"loop": True,
|
|
45
|
+
},
|
|
46
|
+
"character-body": {
|
|
47
|
+
"description": "Avatar/character with skeleton rig: idle, walk, emote",
|
|
48
|
+
"framework": "lottie",
|
|
49
|
+
"default_duration_s": 1.5,
|
|
50
|
+
"fps": 30,
|
|
51
|
+
"loop": True,
|
|
52
|
+
},
|
|
53
|
+
"icon-animation": {
|
|
54
|
+
"description": "Animated icons, path-draw reveals, state glyphs",
|
|
55
|
+
"framework": "lottie",
|
|
56
|
+
"default_duration_s": 0.8,
|
|
57
|
+
"fps": 60,
|
|
58
|
+
"loop": False,
|
|
59
|
+
},
|
|
60
|
+
"scroll-linked": {
|
|
61
|
+
"description": "Parallax, progress-bound reveals, pin animations",
|
|
62
|
+
"framework": "gsap",
|
|
63
|
+
"default_duration_s": 1.0,
|
|
64
|
+
"fps": 60,
|
|
65
|
+
"loop": False,
|
|
66
|
+
},
|
|
67
|
+
"data-viz": {
|
|
68
|
+
"description": "Chart transitions, counting numbers, graph builds",
|
|
69
|
+
"framework": "gsap",
|
|
70
|
+
"default_duration_s": 1.2,
|
|
71
|
+
"fps": 60,
|
|
72
|
+
"loop": False,
|
|
73
|
+
},
|
|
74
|
+
"3d-scene": {
|
|
75
|
+
"description": "WebGL/Three.js scenes, model turntables, shaders",
|
|
76
|
+
"framework": "threejs",
|
|
77
|
+
"default_duration_s": 5.0,
|
|
78
|
+
"fps": 60,
|
|
79
|
+
"loop": True,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_json(path: Path):
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_stack(project_root: Path, pkg: dict | None) -> dict:
|
|
92
|
+
stack = {"framework": None, "react": False, "vue": False, "react_native": False, "native_web": False}
|
|
93
|
+
deps = set()
|
|
94
|
+
if pkg:
|
|
95
|
+
deps.update(pkg.get("dependencies", {}).keys())
|
|
96
|
+
deps.update(pkg.get("devDependencies", {}).keys())
|
|
97
|
+
stack["react"] = bool({"react", "next", "framer-motion"} & deps)
|
|
98
|
+
stack["vue"] = bool({"vue", "nuxt"} & deps)
|
|
99
|
+
stack["react_native"] = bool({"react-native"} & deps)
|
|
100
|
+
stack["native_web"] = bool({"lottie-web", "dotlottie-web", "gsap", "animejs"} & deps)
|
|
101
|
+
if "framer-motion" in deps:
|
|
102
|
+
stack["framework"] = "framer-motion"
|
|
103
|
+
elif "gsap" in deps:
|
|
104
|
+
stack["framework"] = "gsap"
|
|
105
|
+
elif "dotlottie-web" in deps or "lottie-web" in deps or "@lottiefiles/react-lottie-player" in deps:
|
|
106
|
+
stack["framework"] = "lottie"
|
|
107
|
+
elif "three" in deps:
|
|
108
|
+
stack["framework"] = "threejs"
|
|
109
|
+
elif stack["react"]:
|
|
110
|
+
stack["framework"] = "framer-motion"
|
|
111
|
+
else:
|
|
112
|
+
stack["framework"] = "gsap"
|
|
113
|
+
return stack
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract_brand_tokens(project_root: Path) -> dict:
|
|
117
|
+
tokens = {"primary": None, "accent": None, "palette": [], "fonts": []}
|
|
118
|
+
# Tailwind config
|
|
119
|
+
for candidate in ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs"]:
|
|
120
|
+
p = project_root / candidate
|
|
121
|
+
if p.exists():
|
|
122
|
+
text = p.read_text(encoding="utf-8")
|
|
123
|
+
for m in re.finditer(r"primary:\s*['\"](#[0-9a-fA-F]{3,8})['\"]", text):
|
|
124
|
+
tokens["primary"] = m.group(1).upper()
|
|
125
|
+
for m in re.finditer(r"accent:\s*['\"](#[0-9a-fA-F]{3,8})['\"]", text):
|
|
126
|
+
tokens["accent"] = m.group(1).upper()
|
|
127
|
+
# package.json theme / CSS variables
|
|
128
|
+
css_vars = {}
|
|
129
|
+
css_files = list(project_root.rglob("*.css")) + list(project_root.rglob("*.scss"))
|
|
130
|
+
for f in css_files[:20]:
|
|
131
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
132
|
+
for m in re.finditer(r"--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})", text):
|
|
133
|
+
css_vars[m.group(1)] = m.group(2).upper()
|
|
134
|
+
for key in ("color-primary", "primary", "brand", "--primary"):
|
|
135
|
+
if key in css_vars:
|
|
136
|
+
tokens["primary"] = tokens["primary"] or css_vars[key]
|
|
137
|
+
return tokens
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def detect_motion_language(project_root: Path) -> dict:
|
|
141
|
+
"""Gather existing easing/duration conventions from the project."""
|
|
142
|
+
easings = set()
|
|
143
|
+
durations = set()
|
|
144
|
+
for pattern in ("*.ts", "*.tsx", "*.js", "*.jsx", "*.css"):
|
|
145
|
+
for f in project_root.rglob(pattern):
|
|
146
|
+
if "node_modules" in f.parts:
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
150
|
+
except OSError:
|
|
151
|
+
continue
|
|
152
|
+
if len(text) > 200_000:
|
|
153
|
+
continue
|
|
154
|
+
easings.update(re.findall(r"ease(in-out|in|out|linear)?", text))
|
|
155
|
+
durations.update(re.findall(r"(?:duration|time)[^;]{0,20}?:\s*['\"]?([\d.]+(?:s|ms))", text, re.I))
|
|
156
|
+
return {
|
|
157
|
+
"easings_used": sorted(easings)[:10],
|
|
158
|
+
"duration_patterns": sorted(durations)[:10],
|
|
159
|
+
"recommendation": "ease-in-out" if "in-out" in easings else "ease-out",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def find_existing_animations(project_root: Path) -> list:
|
|
164
|
+
found = []
|
|
165
|
+
for ext in ("*.lottie", "*.json", "*.riv"):
|
|
166
|
+
for f in project_root.rglob(ext):
|
|
167
|
+
rel = str(f.relative_to(project_root))
|
|
168
|
+
if "node_modules" in rel or ".git" in rel:
|
|
169
|
+
continue
|
|
170
|
+
if ext == "*.json":
|
|
171
|
+
text = f.read_text(encoding="utf-8", errors="ignore")[:200]
|
|
172
|
+
if '"v"' not in text and "anim" not in text.lower():
|
|
173
|
+
continue
|
|
174
|
+
found.append(rel)
|
|
175
|
+
if len(found) >= 15:
|
|
176
|
+
return found
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def analyze(project_root: str) -> dict:
|
|
181
|
+
root = Path(project_root).resolve()
|
|
182
|
+
pkg = read_json(root / "package.json")
|
|
183
|
+
manifest = read_json(root / "project-manifest.json")
|
|
184
|
+
readme = (root / "README.md").read_text(encoding="utf-8", errors="ignore")[:3000] if (root / "README.md").exists() else ""
|
|
185
|
+
|
|
186
|
+
brand = extract_brand_tokens(root)
|
|
187
|
+
# project-manifest.json is the explicit project contract and therefore
|
|
188
|
+
# overrides inferred values from Tailwind/CSS when both are present.
|
|
189
|
+
manifest_brand = (manifest or {}).get("brand") or {}
|
|
190
|
+
for key in ("primary", "accent"):
|
|
191
|
+
if manifest_brand.get(key):
|
|
192
|
+
brand[key] = str(manifest_brand[key]).upper()
|
|
193
|
+
context = {
|
|
194
|
+
"schema_version": "1.1",
|
|
195
|
+
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
196
|
+
"project_root": str(root),
|
|
197
|
+
"name": (pkg or {}).get("name") or root.name,
|
|
198
|
+
"description": (manifest or {}).get("description") or (pkg or {}).get("description") or "",
|
|
199
|
+
"stack": detect_stack(root, pkg),
|
|
200
|
+
"brand": brand,
|
|
201
|
+
"motion_language": detect_motion_language(root),
|
|
202
|
+
"existing_animations": find_existing_animations(root),
|
|
203
|
+
"manifest_overrides": manifest or {},
|
|
204
|
+
"source_authority": "project-manifest.json then assets/library/, never invented geometry",
|
|
205
|
+
}
|
|
206
|
+
return context
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
parser = argparse.ArgumentParser(description="Analyze a host project and emit its binding context.")
|
|
211
|
+
parser.add_argument("project_root", nargs="?", default=".")
|
|
212
|
+
parser.add_argument("--output", help="Context path; defaults to <project_root>/project-context.json")
|
|
213
|
+
args = parser.parse_args()
|
|
214
|
+
root = Path(args.project_root).resolve()
|
|
215
|
+
if not root.is_dir():
|
|
216
|
+
parser.error(f"project root is not a directory: {root}")
|
|
217
|
+
ctx = analyze(str(root))
|
|
218
|
+
out = Path(args.output).resolve() if args.output else root / "project-context.json"
|
|
219
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
out.write_text(json.dumps(ctx, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
221
|
+
print(f"{out} written ({len(json.dumps(ctx))} bytes)")
|
|
222
|
+
print(json.dumps(ctx, indent=2))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
snapshot.py — Step 5 support: render deterministic PNG snapshots of a scene.
|
|
4
|
+
|
|
5
|
+
For code-based scenes (GSAP / Framer Motion) it drives a headless Chromium
|
|
6
|
+
via the Dev Lab snapshot harness; for Lottie files it renders frames with
|
|
7
|
+
the dotlottie runtime in Node. Output lands in src/output/<scene>/snapshot/
|
|
8
|
+
as `frame-00.png`, `frame-50.png`, `frame-100.png` so visual diffs and PR
|
|
9
|
+
reviews are bit-exact reproducible.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 src/core/snapshot.py render <scene> --scene-dir src/output/<scene> --progress 0,50,100
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fallback_placeholder_frames(scene_dir: Path, progress: list[int], framework: str) -> None:
|
|
24
|
+
"""Deterministic placeholder frames when the headless harness is not installed.
|
|
25
|
+
Frames embed the scene id, progress and framework so diffs still detect change."""
|
|
26
|
+
from PIL import Image, ImageDraw
|
|
27
|
+
|
|
28
|
+
out = scene_dir / "snapshot"
|
|
29
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
color = {"lottie": (245, 158, 11), "gsap": (59, 130, 246), "framer-motion": (34, 197, 94)}.get(
|
|
31
|
+
framework, (140, 140, 160))
|
|
32
|
+
for p in progress:
|
|
33
|
+
img = Image.new("RGB", (320, 200), (20, 18, 26))
|
|
34
|
+
d = ImageDraw.Draw(img)
|
|
35
|
+
d.rectangle([40, 60, 40 + int(2.4 * p), 100], fill=color)
|
|
36
|
+
d.text((20, 150), f"{scene_dir.name} @{p}% [{framework}] placeholder", fill=(200, 200, 210))
|
|
37
|
+
img.save(out / f"frame-{p:02d}.png")
|
|
38
|
+
(out / ".render-meta.json").write_text(json.dumps({
|
|
39
|
+
"mode": "placeholder", "scene": scene_dir.name, "framework": framework,
|
|
40
|
+
"progress": progress,
|
|
41
|
+
}, indent=2) + "\n", encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_render_meta(scene_dir: Path, scene: str, framework: str, progress: list[int], mode: str) -> None:
|
|
45
|
+
out = scene_dir / "snapshot"
|
|
46
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
(out / ".render-meta.json").write_text(json.dumps({
|
|
48
|
+
"mode": mode, "scene": scene, "framework": framework, "progress": progress,
|
|
49
|
+
}, indent=2) + "\n", encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_snapshots(scene: str, scene_dir: Path, progress: list[int], allow_placeholder: bool = False) -> dict:
|
|
53
|
+
if not scene_dir.is_dir():
|
|
54
|
+
raise SystemExit(f"error: scene directory not found: {scene_dir}")
|
|
55
|
+
if any(p < 0 or p > 100 for p in progress):
|
|
56
|
+
raise SystemExit("error: progress values must be between 0 and 100")
|
|
57
|
+
manifest_path = scene_dir / "manifest.json"
|
|
58
|
+
if not manifest_path.exists():
|
|
59
|
+
raise SystemExit(f"error: scene manifest missing: {manifest_path}")
|
|
60
|
+
try:
|
|
61
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise SystemExit(f"error: invalid scene manifest: {exc}")
|
|
64
|
+
|
|
65
|
+
framework = manifest.get("framework", "unknown")
|
|
66
|
+
src = manifest.get("file", "")
|
|
67
|
+
if not framework or framework == "unknown":
|
|
68
|
+
raise SystemExit("error: manifest.framework is required")
|
|
69
|
+
shots = []
|
|
70
|
+
|
|
71
|
+
if framework in ("lottie", "dotlottie") and src.endswith((".lottie", ".json")):
|
|
72
|
+
# Node path using @napi-rs/canvas + dotlottie (see scripts/render-node.mjs)
|
|
73
|
+
runner = Path(__file__).parent.parent.parent / "scripts" / "render-node.mjs"
|
|
74
|
+
source_path = (scene_dir / src).resolve()
|
|
75
|
+
if not source_path.is_file() or scene_dir.resolve() not in source_path.parents:
|
|
76
|
+
raise SystemExit(f"error: manifest.file must point to a file inside the scene directory: {src}")
|
|
77
|
+
(scene_dir / "snapshot").mkdir(parents=True, exist_ok=True)
|
|
78
|
+
for p in progress:
|
|
79
|
+
out_png = scene_dir / "snapshot" / f"frame-{p:02d}.png"
|
|
80
|
+
env = {**os.environ, "FRAME_PCT": str(p), "SRC": str(source_path), "OUT": str(out_png)}
|
|
81
|
+
subprocess.run(["node", str(runner)], env=env, check=True,
|
|
82
|
+
capture_output=True)
|
|
83
|
+
shots.append(str(out_png))
|
|
84
|
+
_write_render_meta(scene_dir, scene, framework, progress, "runtime")
|
|
85
|
+
else:
|
|
86
|
+
# Headless Chromium via Dev Lab snapshot harness
|
|
87
|
+
lab = Path(__file__).parent.parent.parent / "dev-lab"
|
|
88
|
+
harness = lab / "scripts" / "snapshot.mjs"
|
|
89
|
+
try:
|
|
90
|
+
subprocess.run(
|
|
91
|
+
["node", str(harness), "--scene", scene,
|
|
92
|
+
"--progress", ",".join(map(str, progress)), "--out", str(scene_dir / "snapshot")],
|
|
93
|
+
cwd=lab, check=True, capture_output=True,
|
|
94
|
+
)
|
|
95
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
96
|
+
if not allow_placeholder:
|
|
97
|
+
detail = getattr(exc, "stderr", b"")
|
|
98
|
+
if isinstance(detail, bytes):
|
|
99
|
+
detail = detail.decode(errors="replace")
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
"error: runtime snapshot failed; start the Dev Lab and install its renderer "
|
|
102
|
+
f"dependencies before accepting this scene. {detail[-500:]}"
|
|
103
|
+
)
|
|
104
|
+
print("warn: runtime harness unavailable; writing explicitly marked placeholder frames", file=sys.stderr)
|
|
105
|
+
_fallback_placeholder_frames(scene_dir, progress, framework)
|
|
106
|
+
shots = sorted(str(p) for p in (scene_dir / "snapshot").glob("frame-*.png"))
|
|
107
|
+
|
|
108
|
+
return {"scene": scene, "framework": framework, "shots": shots}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main():
|
|
112
|
+
parser = argparse.ArgumentParser()
|
|
113
|
+
parser.add_argument("cmd", choices=["render"])
|
|
114
|
+
parser.add_argument("scene")
|
|
115
|
+
parser.add_argument("--scene-dir", required=True)
|
|
116
|
+
parser.add_argument("--progress", default="0,50,100")
|
|
117
|
+
parser.add_argument("--allow-placeholder", action="store_true", help="diagnostic only; quality gate rejects these frames")
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
result = render_snapshots(args.scene, Path(args.scene_dir), [int(x) for x in args.progress.split(",")], args.allow_placeholder)
|
|
120
|
+
print(json.dumps(result, indent=2))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|