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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/SKILL.md +122 -0
  4. package/agent-card.json +161 -0
  5. package/assets/library/ATTRIBUTION.md +6 -0
  6. package/assets/library/README.md +20 -0
  7. package/assets/library/avatar-base.svg +19 -0
  8. package/assets/library/error-alert.json +1 -0
  9. package/assets/library/rive/ATTRIBUTION.md +12 -0
  10. package/assets/library/rive/state-machine-test.riv +0 -0
  11. package/assets/library/success-check.json +1 -0
  12. package/bin/motionloom.mjs +81 -0
  13. package/docs/BROWSER-REVIEW-E2E.md +78 -0
  14. package/docs/CATEGORIES.md +16 -0
  15. package/docs/CHECKLIST.md +31 -0
  16. package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
  17. package/docs/FRAMEWORK-SELECTION.md +26 -0
  18. package/docs/PROJECT-MANIFEST.md +37 -0
  19. package/docs/ROADMAP-INTELLIGENCE.md +224 -0
  20. package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
  21. package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
  22. package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
  23. package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
  24. package/docs/releases/1.5.0.md +25 -0
  25. package/docs/releases/1.6.0.md +27 -0
  26. package/docs/releases/1.7.0.md +23 -0
  27. package/docs/releases/1.8.0.md +23 -0
  28. package/docs/releases/1.9.0.md +21 -0
  29. package/docs/releases/2.0.0.md +21 -0
  30. package/docs/releases/npm-publish-from-workstation.md +88 -0
  31. package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
  32. package/examples/report-demo/REPORT.md +50 -0
  33. package/examples/report-demo/artifact-manifest.json +25 -0
  34. package/examples/report-demo/decision-log.jsonl +0 -0
  35. package/examples/report-demo/execution-report.json +70 -0
  36. package/examples/report-demo/handoff.json +22 -0
  37. package/examples/report-demo/issue-register.json +5 -0
  38. package/examples/report-demo/task.json +13 -0
  39. package/package.json +95 -0
  40. package/project-context.example.json +26 -0
  41. package/references/browser-review-contract.md +32 -0
  42. package/references/dotlottie-source-notes.md +21 -0
  43. package/references/intelligence-core.md +98 -0
  44. package/references/reporting-contract.md +38 -0
  45. package/references/runtime-capability.md +12 -0
  46. package/references/signed-attestation.md +31 -0
  47. package/schemas/artifact-manifest.schema.json +22 -0
  48. package/schemas/browser-review-candidate.schema.json +24 -0
  49. package/schemas/capability-registry.schema.json +61 -0
  50. package/schemas/continuity-report.schema.json +52 -0
  51. package/schemas/evidence-verifier-report.schema.json +35 -0
  52. package/schemas/execution-report.schema.json +35 -0
  53. package/schemas/fix-plan.schema.json +44 -0
  54. package/schemas/handoff.schema.json +19 -0
  55. package/schemas/motion-ir.schema.json +74 -0
  56. package/schemas/project-graph.schema.json +70 -0
  57. package/schemas/provenance.schema.json +76 -0
  58. package/schemas/runtime-evidence.schema.json +48 -0
  59. package/schemas/runtime-telemetry.schema.json +50 -0
  60. package/schemas/scene-manifest.schema.json +45 -0
  61. package/schemas/semantic-benchmark.schema.json +26 -0
  62. package/schemas/semantic-lint-report.schema.json +48 -0
  63. package/schemas/signed-attestation.schema.json +95 -0
  64. package/schemas/task.schema.json +39 -0
  65. package/schemas/trust-policy.schema.json +53 -0
  66. package/scripts/analyze.sh +13 -0
  67. package/scripts/attestation-keygen.py +63 -0
  68. package/scripts/attestation-verifier.py +178 -0
  69. package/scripts/attestation.py +288 -0
  70. package/scripts/capture-runtime-telemetry.sh +37 -0
  71. package/scripts/devlab.sh +77 -0
  72. package/scripts/eval-intelligence.py +377 -0
  73. package/scripts/evidence-verifier.py +222 -0
  74. package/scripts/fetch-library.sh +57 -0
  75. package/scripts/intelligence.py +1543 -0
  76. package/scripts/manifest.py +61 -0
  77. package/scripts/pr.sh +103 -0
  78. package/scripts/quality-gate.py +378 -0
  79. package/scripts/render-node.mjs +53 -0
  80. package/scripts/render.sh +37 -0
  81. package/scripts/report-contract.py +181 -0
  82. package/scripts/report.py +588 -0
  83. package/scripts/review-hook.py +199 -0
  84. package/scripts/runtime-adapters.mjs +187 -0
  85. package/scripts/skill-doctor.py +150 -0
  86. package/scripts/to-dotlottie.mjs +99 -0
  87. package/scripts/to-dotlottie.sh +25 -0
  88. package/scripts/validate-lottie.py +102 -0
  89. package/src/core/analyzer.py +226 -0
  90. package/src/core/snapshot.py +124 -0
  91. package/src/core/spec.py +240 -0
  92. package/src/output/browser-review-smoke/animation.json +57 -0
  93. package/src/output/browser-review-smoke/browser-review.json +21 -0
  94. package/src/output/browser-review-smoke/manifest.json +22 -0
  95. package/src/output/browser-review-smoke/motion-spec.json +28 -0
  96. package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
  97. package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
  98. package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
  99. package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
  100. package/src/rig/README.md +35 -0
  101. package/src/rig/cutout_rig.py +211 -0
  102. package/templates/framer-motion/ui-micro.tsx +50 -0
  103. package/templates/gsap/scroll-scene.js +54 -0
  104. package/templates/lottie/README.md +21 -0
  105. package/templates/lottie/react-component.tsx +82 -0
  106. package/templates/lottie/scaffold/animation.json +57 -0
  107. package/templates/lottie/scaffold/character-rig.svg +19 -0
  108. package/templates/lottie/vanilla.js +68 -0
  109. package/templates/rive/README.md +36 -0
  110. package/tests/evals/intelligence-cases.json +131 -0
  111. package/tests/scripts/run_tests.py +843 -0
  112. 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()