@litfamily/litopencode 1.0.30 → 1.0.32

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 (270) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/activation-probe.js +1 -0
  5. package/dist/activation-prompt-utils.d.ts +1 -1
  6. package/dist/activation-reentry.d.ts +20 -0
  7. package/dist/activation-reentry.js +110 -1
  8. package/dist/activation-routing.d.ts +2 -1
  9. package/dist/activation-routing.js +100 -11
  10. package/dist/activation-workflow-prompts.d.ts +4 -0
  11. package/dist/activation-workflow-prompts.js +30 -0
  12. package/dist/activation.d.ts +1 -1
  13. package/dist/activation.js +44 -5
  14. package/dist/adhd-next.d.ts +51 -0
  15. package/dist/adhd-next.js +150 -0
  16. package/dist/cli/managed-skill-assets.d.ts +1 -1
  17. package/dist/cli/managed-skill-assets.js +7 -1
  18. package/dist/cli/native-skill-integrity.js +1 -1
  19. package/dist/cli.js +7 -1
  20. package/dist/commands.d.ts +28 -1
  21. package/dist/commands.js +48 -1
  22. package/dist/features.d.ts +80 -1
  23. package/dist/features.js +105 -1
  24. package/dist/ledger.js +12 -1
  25. package/dist/secret-shapes.js +5 -5
  26. package/dist/skills.d.ts +22 -1
  27. package/dist/skills.js +36 -0
  28. package/dist/start-work-typed.d.ts +34 -0
  29. package/dist/start-work-typed.js +156 -0
  30. package/dist/state.d.ts +2 -0
  31. package/dist/state.js +2 -0
  32. package/dist/tools.js +22 -2
  33. package/dist/tui/mission-band.d.ts +20 -1
  34. package/dist/tui/mission-band.js +73 -16
  35. package/dist/tui/mission-looks.d.ts +38 -0
  36. package/dist/tui/mission-looks.js +73 -0
  37. package/dist/tui/mission-model.d.ts +2 -0
  38. package/dist/tui/mission-model.js +3 -1
  39. package/dist/tui/mission-rainbow.d.ts +33 -0
  40. package/dist/tui/mission-rainbow.js +90 -0
  41. package/dist/tui/mission-source.d.ts +48 -0
  42. package/dist/tui/mission-source.js +184 -2
  43. package/dist/tui/mission-text.d.ts +2 -0
  44. package/dist/tui/mission-text.js +9 -1
  45. package/dist/tui/mission-threads.d.ts +50 -0
  46. package/dist/tui/mission-threads.js +119 -0
  47. package/dist/tui.js +84 -6
  48. package/dist/uiux-visual-catalog.js +8 -1
  49. package/dist/v2/session-hooks.js +38 -5
  50. package/dist/workflow-stack.d.ts +53 -0
  51. package/dist/workflow-stack.js +196 -0
  52. package/docs/assets/readme/badge-version.svg +1 -1
  53. package/docs/assets/skills/lit-schematic.webp +0 -0
  54. package/docs/reference-Ko-KR.md +30 -2
  55. package/docs/reference.md +34 -2
  56. package/package.json +1 -1
  57. package/skills/frontend-ui-ux/SKILL.md +3 -2
  58. package/skills/frontend-ui-ux/kit/components.css +414 -0
  59. package/skills/frontend-ui-ux/kit/fonts.json +22 -0
  60. package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  61. package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  62. package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  63. package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  64. package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  65. package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  66. package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  67. package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  68. package/skills/frontend-ui-ux/kit/ui.js +226 -0
  69. package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
  70. package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  71. package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
  72. package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
  73. package/skills/frontend-ui-ux/references/house-styles.md +236 -0
  74. package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
  75. package/skills/frontend-ui-ux/references/production.md +3 -1
  76. package/skills/frontend-ui-ux/references/slop-register.md +4 -4
  77. package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  78. package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  79. package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
  80. package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
  81. package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
  82. package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
  83. package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
  84. package/skills/lit-adhd/SKILL.md +208 -0
  85. package/skills/lit-adhd/references/brief-format.md +43 -0
  86. package/skills/lit-adhd/references/card-examples.md +82 -0
  87. package/skills/lit-adhd/references/status-taxonomy.md +29 -0
  88. package/skills/lit-adhd/scripts/rewind.mjs +835 -0
  89. package/skills/lit-diagram-drawer/SKILL.md +4 -3
  90. package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  91. package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  92. package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  93. package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  94. package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  95. package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
  96. package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
  97. package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
  98. package/skills/lit-docx/SKILL.md +2 -1
  99. package/skills/lit-handoff/SKILL.md +2 -0
  100. package/skills/lit-hijack/SKILL.md +422 -0
  101. package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
  102. package/skills/lit-hijack/references/clean-room-build.md +111 -0
  103. package/skills/lit-hijack/references/distil-and-licence.md +100 -0
  104. package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
  105. package/skills/lit-hijack/references/parity-and-report.md +110 -0
  106. package/skills/lit-hijack/scripts/hijack.mjs +993 -0
  107. package/skills/lit-plan/SKILL.md +2 -0
  108. package/skills/lit-pptx/SKILL.md +27 -11
  109. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  110. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  111. package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  112. package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
  113. package/skills/lit-pptx/references/density-and-fill.md +9 -3
  114. package/skills/lit-pptx/references/direction-step.md +21 -6
  115. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
  116. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
  117. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
  118. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
  119. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
  120. package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  121. package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  122. package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  123. package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  124. package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  125. package/skills/lit-pptx/references/layout-families.md +65 -47
  126. package/skills/lit-pptx/references/title-treatments.md +83 -134
  127. package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
  128. package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  129. package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
  130. package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  131. package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
  132. package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
  133. package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  134. package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  135. package/skills/lit-pptx/references/tonalities/night.md +1 -1
  136. package/skills/lit-pptx/references/tonalities/paper.md +2 -2
  137. package/skills/lit-pptx/references/tonalities/riso.md +137 -0
  138. package/skills/lit-pptx/references/tonalities/signal.md +3 -3
  139. package/skills/lit-pptx/references/tonalities/studio.md +7 -6
  140. package/skills/lit-pptx/run.mjs +2 -1
  141. package/skills/lit-pptx/scripts/compile-deck.js +1 -1
  142. package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
  143. package/skills/lit-pptx/scripts/craft_extras.py +56 -3
  144. package/skills/lit-pptx/scripts/deck_output.py +75 -90
  145. package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  146. package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
  147. package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  148. package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  149. package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
  150. package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  151. package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  152. package/skills/lit-pptx/scripts/qa_deck.py +10 -11
  153. package/skills/lit-pptx/scripts/render_pages.py +118 -0
  154. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  155. package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  156. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  157. package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  158. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  159. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  160. package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  161. package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  162. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  163. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  164. package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  165. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  166. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  167. package/skills/lit-recap/SKILL.md +4 -0
  168. package/skills/lit-schematic/SKILL.md +118 -0
  169. package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  170. package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  171. package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  172. package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  173. package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  174. package/skills/lit-schematic/references/isometric-craft.md +101 -0
  175. package/skills/lit-schematic/references/journal-figures.md +72 -0
  176. package/skills/lit-schematic/references/kit-api.md +134 -0
  177. package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  178. package/skills/lit-schematic/references/pid-drafting.md +103 -0
  179. package/skills/lit-schematic/references/pid-symbols.md +163 -0
  180. package/skills/lit-schematic/references/verification.md +100 -0
  181. package/skills/lit-schematic/scripts/doctor.mjs +57 -0
  182. package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  183. package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
  184. package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  185. package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  186. package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  187. package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  188. package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  189. package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  190. package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  191. package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  192. package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  193. package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  194. package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  195. package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  196. package/skills/lit-schematic/scripts/motion.mjs +129 -0
  197. package/skills/lit-schematic/scripts/qa.mjs +92 -0
  198. package/skills/lit-schematic/scripts/render.mjs +233 -0
  199. package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  200. package/skills/lit-scientific-visualization/SKILL.md +27 -4
  201. package/skills/lit-typographic-motion/SKILL.md +19 -17
  202. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
  203. package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
  204. package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
  205. package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
  206. package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
  207. package/skills/lit-typographic-motion/engine/board.mjs +138 -0
  208. package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
  209. package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
  210. package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
  211. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
  212. package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
  213. package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
  214. package/skills/lit-typographic-motion/engine/house.mjs +42 -0
  215. package/skills/lit-typographic-motion/engine/look.mjs +65 -8
  216. package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
  217. package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
  218. package/skills/lit-typographic-motion/engine/score.mjs +594 -0
  219. package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
  220. package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
  221. package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
  222. package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
  223. package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
  224. package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
  225. package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
  226. package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
  227. package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
  228. package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
  229. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
  230. package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
  231. package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
  232. package/skills/lit-typographic-motion/engine/text.mjs +1 -1
  233. package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
  234. package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  235. package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  236. package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  237. package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  238. package/skills/lit-typographic-motion/gate.mjs +18 -2
  239. package/skills/lit-typographic-motion/probe.mjs +13 -1
  240. package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
  241. package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
  242. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
  243. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
  244. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
  245. package/skills/lit-typographic-motion/references/cover.md +56 -13
  246. package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
  247. package/skills/lit-typographic-motion/references/depth.md +112 -0
  248. package/skills/lit-typographic-motion/references/runtime.md +21 -6
  249. package/skills/lit-typographic-motion/references/sound.md +76 -0
  250. package/skills/lit-typographic-motion/references/stage.md +50 -7
  251. package/skills/lit-typographic-motion/references/studio.md +162 -0
  252. package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
  253. package/skills/lit-typographic-motion/references/treatment.md +108 -9
  254. package/skills/lit-typographic-motion/render.mjs +60 -3
  255. package/skills/lit-typographic-motion/requirements-audio.txt +3 -3
  256. package/skills/lit-typographic-motion/styles/paper.json +185 -0
  257. package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
  258. package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
  259. package/skills/lit-typographic-motion/styles/tidal.json +187 -0
  260. package/skills/litresearch/SKILL.md +2 -0
  261. package/skills/managed-skill-manifest.json +520 -91
  262. package/skills/readme-studio/SKILL.md +1 -1
  263. package/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
  264. package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  265. package/skills/review-work/SKILL.md +2 -0
  266. package/skills/start-work/SKILL.md +8 -0
  267. package/skills/workflow-loop/SKILL.md +4 -0
  268. package/tools/check-pack-payload.mjs +4 -2
  269. package/tools/payload-substance-parity.json +4 -3
  270. package/tools/qa-cover-recipes.mjs +4 -3
@@ -0,0 +1,228 @@
1
+ # lit-typographic-motion: the offline Blender rung. Blender runs this headless:
2
+ # blender -b --factory-startup -P runner.py -- --scene scene.py --out DIR --width W --height H
3
+ # --percent P --samples S --device METAL --fps F --seed N (--stills t,t,... | --frames 1-N)
4
+ # Cycles goes on the GPU (the wanted backend first, then any other that has a device, else the CPU,
5
+ # and the receipt names which). The film's scene.py then runs with the `lit` helpers in scope, and the
6
+ # runner renders one still per listed time (still-01.png ...) or the frame sequence (000001.png ...)
7
+ # and writes receipt.json: the Blender version, engine, device, samples, size and the files written.
8
+ import argparse
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+
14
+ import bpy
15
+ from mathutils import Vector
16
+
17
+ GPU_BACKENDS = ("METAL", "OPTIX", "CUDA", "HIP", "ONEAPI")
18
+
19
+
20
+ def arguments():
21
+ rest = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
22
+ parser = argparse.ArgumentParser(prog="runner.py")
23
+ parser.add_argument("--scene", required=True)
24
+ parser.add_argument("--out", required=True)
25
+ parser.add_argument("--width", type=int, required=True)
26
+ parser.add_argument("--height", type=int, required=True)
27
+ parser.add_argument("--percent", type=int, default=100)
28
+ parser.add_argument("--samples", type=int, default=128)
29
+ parser.add_argument("--device", default="METAL")
30
+ parser.add_argument("--fps", type=int, default=30)
31
+ parser.add_argument("--seed", type=int, default=0)
32
+ parser.add_argument("--stills")
33
+ parser.add_argument("--frames")
34
+ return parser.parse_args(rest)
35
+
36
+
37
+ def use_gpu(scene, wanted):
38
+ """Put Cycles on a GPU backend that has a device, the wanted one first; the CPU when none does."""
39
+ prefs = bpy.context.preferences.addons["cycles"].preferences
40
+ for backend in dict.fromkeys((wanted, *GPU_BACKENDS)):
41
+ try:
42
+ prefs.compute_device_type = backend
43
+ except TypeError:
44
+ continue
45
+ prefs.get_devices()
46
+ chosen = [d for d in prefs.devices if d.type == backend]
47
+ if chosen:
48
+ for d in prefs.devices:
49
+ d.use = d.type == backend
50
+ scene.cycles.device = "GPU"
51
+ return backend
52
+ scene.cycles.device = "CPU"
53
+ return "CPU"
54
+
55
+
56
+ def srgb_to_linear(value):
57
+ return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
58
+
59
+
60
+ class Lit:
61
+ """What scene.py may call. Times are film seconds; the plate's first frame is frame 1."""
62
+
63
+ def __init__(self, fps):
64
+ self.fps = fps
65
+
66
+ def frame(self, t):
67
+ return int(round(t * self.fps)) + 1
68
+
69
+ def key(self, obj, prop, t, value=None, index=-1):
70
+ """Set obj.<prop> when a value is given, then keyframe it at film time t."""
71
+ if value is not None:
72
+ setattr(obj, prop, value)
73
+ obj.keyframe_insert(data_path=prop, frame=self.frame(t), index=index)
74
+
75
+ def rgb(self, color):
76
+ """A #RRGGBB string, or a tuple already in linear space, as Blender's linear colour."""
77
+ if not isinstance(color, str):
78
+ return tuple(color)[:3]
79
+ digits = color.lstrip("#")
80
+ return tuple(srgb_to_linear(int(digits[i:i + 2], 16) / 255) for i in (0, 2, 4))
81
+
82
+ def look_at(self, obj, target):
83
+ aim = Vector(target) - obj.location
84
+ obj.rotation_euler = aim.to_track_quat("-Z", "Y").to_euler()
85
+
86
+ def material(self, name, color, roughness=0.4, metallic=0.0, emission=None, strength=0.0):
87
+ mat = bpy.data.materials.new(name)
88
+ mat.use_nodes = True
89
+ shader = mat.node_tree.nodes["Principled BSDF"]
90
+ shader.inputs["Base Color"].default_value = (*self.rgb(color), 1.0)
91
+ shader.inputs["Roughness"].default_value = roughness
92
+ shader.inputs["Metallic"].default_value = metallic
93
+ if emission is not None:
94
+ shader.inputs["Emission Color"].default_value = (*self.rgb(emission), 1.0)
95
+ shader.inputs["Emission Strength"].default_value = strength
96
+ return mat
97
+
98
+ def rounded_box(self, name, size, radius=0.08, segments=6, material=None, location=(0, 0, 0)):
99
+ """A box whose bevelled edges catch the light."""
100
+ bpy.ops.mesh.primitive_cube_add(size=1, location=location)
101
+ obj = bpy.context.active_object
102
+ obj.name = name
103
+ obj.scale = size
104
+ bpy.ops.object.transform_apply(scale=True)
105
+ bevel = obj.modifiers.new("bevel", "BEVEL")
106
+ bevel.width = radius
107
+ bevel.segments = segments
108
+ bevel.limit_method = "ANGLE"
109
+ bpy.ops.object.shade_smooth()
110
+ if material is not None:
111
+ obj.data.materials.append(material)
112
+ return obj
113
+
114
+ def image_card(self, name, image_path, width=1.0, location=(0, 0, 0), emission=1.0):
115
+ """A flat card showing a product image file at its own aspect ratio, lit from within."""
116
+ image = bpy.data.images.load(os.path.abspath(image_path))
117
+ w, h = image.size
118
+ bpy.ops.mesh.primitive_plane_add(size=1, location=location)
119
+ obj = bpy.context.active_object
120
+ obj.name = name
121
+ obj.scale = (width, width * h / max(w, 1), 1)
122
+ mat = bpy.data.materials.new(name)
123
+ mat.use_nodes = True
124
+ nodes, links = mat.node_tree.nodes, mat.node_tree.links
125
+ texture = nodes.new("ShaderNodeTexImage")
126
+ texture.image = image
127
+ shader = nodes["Principled BSDF"]
128
+ links.new(texture.outputs["Color"], shader.inputs["Base Color"])
129
+ links.new(texture.outputs["Color"], shader.inputs["Emission Color"])
130
+ links.new(texture.outputs["Alpha"], shader.inputs["Alpha"])
131
+ shader.inputs["Emission Strength"].default_value = emission
132
+ obj.data.materials.append(mat)
133
+ return obj
134
+
135
+ def area_light(self, name, location, target=(0, 0, 0), power=400.0, size=2.0, color="#FFFFFF"):
136
+ data = bpy.data.lights.new(name, "AREA")
137
+ data.energy = power
138
+ data.size = size
139
+ data.color = self.rgb(color)
140
+ obj = bpy.data.objects.new(name, data)
141
+ bpy.context.scene.collection.objects.link(obj)
142
+ obj.location = location
143
+ self.look_at(obj, target)
144
+ return obj
145
+
146
+ def camera(self, location, target=(0, 0, 0), lens=50.0, dof=None):
147
+ """The scene camera aimed at target; dof=(focus_distance, f_stop) turns depth of field on."""
148
+ data = bpy.data.cameras.new("camera")
149
+ data.lens = lens
150
+ if dof is not None:
151
+ data.dof.use_dof = True
152
+ data.dof.focus_distance, data.dof.aperture_fstop = dof
153
+ obj = bpy.data.objects.new("camera", data)
154
+ bpy.context.scene.collection.objects.link(obj)
155
+ obj.location = location
156
+ self.look_at(obj, target)
157
+ bpy.context.scene.camera = obj
158
+ return obj
159
+
160
+ def world(self, color, strength=1.0):
161
+ scene = bpy.context.scene
162
+ scene.world = scene.world or bpy.data.worlds.new("world")
163
+ scene.world.use_nodes = True
164
+ background = scene.world.node_tree.nodes["Background"]
165
+ background.inputs["Color"].default_value = (*self.rgb(color), 1.0)
166
+ background.inputs["Strength"].default_value = strength
167
+ return scene.world
168
+
169
+
170
+ def configure(scene, a):
171
+ scene.render.engine = "CYCLES"
172
+ device = use_gpu(scene, a.device)
173
+ scene.cycles.samples = a.samples
174
+ scene.cycles.seed = a.seed
175
+ scene.cycles.use_denoising = True
176
+ scene.render.resolution_x = a.width
177
+ scene.render.resolution_y = a.height
178
+ scene.render.resolution_percentage = a.percent
179
+ scene.render.fps = a.fps
180
+ scene.render.use_motion_blur = True
181
+ image = scene.render.image_settings
182
+ image.file_format, image.color_mode, image.color_depth = "PNG", "RGB", "8"
183
+ return device
184
+
185
+
186
+ def render_stills(scene, lit, a):
187
+ names = []
188
+ for index, t in enumerate(float(x) for x in a.stills.split(",")):
189
+ scene.frame_set(lit.frame(t))
190
+ names.append(f"still-{index + 1:02d}.png")
191
+ scene.render.filepath = os.path.join(a.out, names[-1])
192
+ bpy.ops.render.render(write_still=True)
193
+ return names
194
+
195
+
196
+ def render_frames(scene, a):
197
+ first, last = (int(x) for x in a.frames.split("-"))
198
+ scene.frame_start, scene.frame_end = first, last
199
+ scene.render.filepath = os.path.join(a.out, "######")
200
+ bpy.ops.render.render(animation=True)
201
+ return [f"{n:06d}.png" for n in range(first, last + 1)]
202
+
203
+
204
+ def main():
205
+ a = arguments()
206
+ os.makedirs(a.out, exist_ok=True)
207
+ bpy.ops.wm.read_factory_settings(use_empty=True)
208
+ scene = bpy.context.scene
209
+ device = configure(scene, a)
210
+ lit = Lit(a.fps)
211
+ with open(a.scene, encoding="utf-8") as handle:
212
+ code = compile(handle.read(), a.scene, "exec")
213
+ exec(code, {"bpy": bpy, "lit": lit, "math": math, "Vector": Vector, "__name__": "__main__", "__file__": a.scene})
214
+ if scene.camera is None:
215
+ raise SystemExit("scene.py made no camera: call lit.camera(...) or set bpy.context.scene.camera")
216
+ scene.cycles.samples = a.samples # the command line has the last word on quality
217
+ scene.render.resolution_percentage = a.percent
218
+ names = render_stills(scene, lit, a) if a.stills else render_frames(scene, a)
219
+ receipt = {
220
+ "blender": bpy.app.version_string, "engine": "CYCLES", "device": device, "samples": a.samples,
221
+ "percent": a.percent, "width": a.width * a.percent // 100, "height": a.height * a.percent // 100,
222
+ "fps": a.fps, "files": [n for n in names if os.path.exists(os.path.join(a.out, n))],
223
+ }
224
+ with open(os.path.join(a.out, "receipt.json"), "w", encoding="utf-8") as handle:
225
+ json.dump(receipt, handle)
226
+
227
+
228
+ main()
@@ -0,0 +1,126 @@
1
+ // The offline Blender rung. A 3D film with `render3d: "blender"` writes its world as a Blender Python
2
+ // scene (<out>/stage/scene.py); `render.mjs blender` renders it headless with Cycles on the GPU through
3
+ // the bundled runner: first preview stills at the shot midpoints (half size, few samples) to look at,
4
+ // then with --final the full plate sequence in <out>/stage/plate/ with plate.json, the receipt the
5
+ // stage trusts. The stage page shows the plate through LitStage.plate() and draws its type and labels
6
+ // over it, so the page itself stays on the software rung. Without Blender the command renders nothing
7
+ // and names the fallback: the three.js world on the GPU rung.
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { EXIT } from "./constants.mjs";
13
+ import { StageError, readPlate } from "./stage/scan.mjs";
14
+ import { frameSize, loadTreatment } from "./treatment.mjs";
15
+
16
+ export { readPlate };
17
+
18
+ export const BLENDER = Object.freeze({
19
+ macApp: "/Applications/Blender.app/Contents/MacOS/Blender",
20
+ runner: fileURLToPath(new URL("./blender/runner.py", import.meta.url)),
21
+ plateFps: 30,
22
+ device: "METAL",
23
+ preview: Object.freeze({ percent: 50, samples: 40 }),
24
+ final: Object.freeze({ percent: 100, samples: 160 })
25
+ });
26
+
27
+ // Where Blender is: an explicit LITOPENCODE_BLENDER path (used only if it exists, never second-guessed),
28
+ // then the macOS app, then `blender` on PATH. Returns { path, source } or null.
29
+ export function findBlender({ env = process.env, exists = existsSync, platform = process.platform } = {}) {
30
+ if (env.LITOPENCODE_BLENDER) return exists(env.LITOPENCODE_BLENDER) ? { path: env.LITOPENCODE_BLENDER, source: "LITOPENCODE_BLENDER" } : null;
31
+ if (platform === "darwin" && exists(BLENDER.macApp)) return { path: BLENDER.macApp, source: "app" };
32
+ for (const dir of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
33
+ const file = path.join(dir, "blender");
34
+ if (exists(file)) return { path: file, source: "PATH" };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export const plateFrames = (treatment) => Math.round(treatment.durationSec * BLENDER.plateFps);
40
+
41
+ // The seed the stage page and the plate share, below 2^31 because Cycles takes a signed int.
42
+ export const plateSeed = (seed) => (seed >>> 0) % 2147483648;
43
+
44
+ // The headless command line: factory settings, the runner, and the runner's own arguments after `--`.
45
+ export function blenderArgs({ treatment, scene, out, quality }) {
46
+ const q = BLENDER[quality];
47
+ if (quality !== "preview" && quality !== "final") throw new Error(`blenderArgs: quality is preview or final (got ${quality})`);
48
+ const { width, height } = frameSize(treatment);
49
+ const args = ["-b", "--factory-startup", "-P", BLENDER.runner, "--",
50
+ "--scene", scene, "--out", out, "--width", String(width), "--height", String(height),
51
+ "--percent", String(q.percent), "--samples", String(q.samples), "--device", BLENDER.device,
52
+ "--fps", String(BLENDER.plateFps), "--seed", String(plateSeed(Number.isSafeInteger(treatment.seed) ? treatment.seed : 0))];
53
+ if (quality === "preview") args.push("--stills", treatment.beats.map((b) => +((b.t0 + b.t1) / 2).toFixed(3)).join(","));
54
+ else args.push("--frames", `1-${plateFrames(treatment)}`);
55
+ return args;
56
+ }
57
+
58
+ const FALLBACK = (out) => [
59
+ "FALLBACK: no Blender found (LITOPENCODE_BLENDER, /Applications/Blender.app, blender on PATH), so nothing was rendered.",
60
+ `Set render3d to "three" in ${path.join(out, "treatment.json")} and build the world in three.js on the GPU rung (references/depth.md),`,
61
+ "and say in the reply that the film used the GPU rung because Blender is not installed."
62
+ ].join("\n");
63
+
64
+ const usage = (detail) => Object.assign(new Error(detail), { exitCode: 2 });
65
+ const listPngs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".png")).sort() : []);
66
+
67
+ function runBlender({ bin, args, dir, expected, progress }) {
68
+ return new Promise((resolve) => {
69
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
70
+ let tail = "";
71
+ const keep = (chunk) => { tail = (tail + chunk).slice(-4000); };
72
+ child.stdout.on("data", keep);
73
+ child.stderr.on("data", keep);
74
+ const timer = progress ? setInterval(() => progress(listPngs(dir).length, expected), 2000) : null;
75
+ const done = (value) => { if (timer) clearInterval(timer); resolve(value); };
76
+ child.on("close", (code) => done({ code, tail }));
77
+ child.on("error", (error) => done({ code: -1, tail: error.message }));
78
+ });
79
+ }
80
+
81
+ // `render.mjs blender --out <dir> [--final]`. Preview: stills in stage/plate-preview/. Final: the plate
82
+ // sequence in stage/plate/ and plate.json listing every frame by name and size, with the device,
83
+ // samples and Blender version from the runner's receipt. Returns { code, message }.
84
+ export async function blenderCommand({ out, final = false, env = process.env, log = () => {}, progress = null }) {
85
+ const { treatment } = loadTreatment(out);
86
+ if (treatment.path !== "stage" || treatment.render3d !== "blender") throw usage(`blender renders a stage treatment with render3d "blender" (this one is ${treatment.path === "stage" ? (treatment.render3d ?? "three") : "a type-path film"})`);
87
+ const stageDir = path.join(out, "stage");
88
+ const scene = path.join(stageDir, "scene.py");
89
+ if (!existsSync(scene)) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${scene} is missing; write the Blender scene first (references/depth.md, the Blender rung)`);
90
+ const found = findBlender({ env });
91
+ if (!found) return { code: EXIT.OK, message: FALLBACK(out) };
92
+ const quality = final ? "final" : "preview";
93
+ const dir = path.join(stageDir, final ? "plate" : "plate-preview");
94
+ rmSync(dir, { recursive: true, force: true });
95
+ mkdirSync(dir, { recursive: true });
96
+ const args = blenderArgs({ treatment, scene, out: dir, quality });
97
+ const expected = final ? plateFrames(treatment) : treatment.beats.length;
98
+ log(`blender ${quality}: ${found.path} (${found.source}), ${expected} ${final ? "frames" : "stills"}`);
99
+ const run = await runBlender({ bin: found.path, args, dir, expected, progress });
100
+ const receiptFile = path.join(dir, "receipt.json");
101
+ if (run.code !== 0 || !existsSync(receiptFile)) {
102
+ throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `Blender ${quality} failed (exit ${run.code}); fix stage/scene.py and run it again. Last output: ${run.tail.trim().split("\n").slice(-8).join(" | ")}`);
103
+ }
104
+ const receipt = JSON.parse(readFileSync(receiptFile, "utf8"));
105
+ const frames = receipt.files.map((name) => ({ name, bytes: statSync(path.join(dir, name)).size }));
106
+ if (frames.length !== expected) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `Blender wrote ${frames.length} of ${expected} ${final ? "frames" : "stills"}`);
107
+ const plate = { kind: quality, blender: receipt.blender, engine: receipt.engine, device: receipt.device, samples: receipt.samples, percent: receipt.percent, fps: BLENDER.plateFps, frames };
108
+ writeFileSync(path.join(dir, "plate.json"), `${JSON.stringify(plate, null, 1)}\n`);
109
+ const label = `Blender ${receipt.blender}, ${receipt.engine} ${receipt.device}, ${receipt.samples} spp at ${receipt.percent} %`;
110
+ if (!final) {
111
+ return { code: EXIT.OK, message: `BLENDER preview: ${frames.length} stills at the shot midpoints in ${dir} (${label}). Open them with the read tool, compare them with any reference frames the user gave, fix scene.py, then render the plate: blender --out ${out} --final` };
112
+ }
113
+ return { code: EXIT.OK, message: `BLENDER plate: ${frames.length} frames at ${BLENDER.plateFps} fps in ${dir} (${label}). Show it with LitStage.plate() under the type, then render the stage: stage --out ${out} --round N` };
114
+ }
115
+
116
+ // A Blender film needs its whole plate before the stage starts: returns the plate, throws exit 17 when
117
+ // plate.json is missing, does not match its frames, or counts a different number of frames than the
118
+ // film's length needs. Any other film returns null.
119
+ export function requirePlate(treatment, stageDir) {
120
+ if (treatment.render3d !== "blender") return null;
121
+ const plate = readPlate(stageDir, "plate");
122
+ if (!plate) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `render3d is blender but stage/plate/plate.json is missing or does not match its frames; render the plate first: blender --out <dir> --final (no Blender: set render3d to "three" and use the GPU rung)`);
123
+ const want = plateFrames(treatment);
124
+ if (plate.frames.length !== want) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `the plate has ${plate.frames.length} frames; a ${treatment.durationSec} s film needs ${want} at ${BLENDER.plateFps} fps (render it again: blender --out <dir> --final)`);
125
+ return plate;
126
+ }
@@ -0,0 +1,138 @@
1
+ // The storyboard: `stills/board.png`, one page a person can read before the full render. One keyframe
2
+ // per shot (the beat still) with the shot's number, time, layout, hand-off and hero move under it and
3
+ // the shot's type zone outlined on the frame; a header with the house style (name, palette, type) and
4
+ // the film's format, length and sound; a footer with the layout check. It is drawn as a page in the same
5
+ // headless Chrome the stage uses, so its type is the product's own faces.
6
+ import { createHash } from "node:crypto";
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { LAYOUTS, readStyle } from "./styles.mjs";
10
+ import { openStage } from "./stage/capture.mjs";
11
+
12
+ export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
13
+
14
+ const esc = (text) => String(text ?? "").replace(/[&<>"]/gu, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
15
+ const secs = (v) => `${Number(v).toFixed(1)}`;
16
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
17
+
18
+ // Tile geometry (columns, thumbnail size) for n shots of a film of size width x height.
19
+ export function boardGrid(n, width, height) {
20
+ const usableW = BOARD.width - 2 * BOARD.margin;
21
+ const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
22
+ const aspect = width / height;
23
+ const options = [];
24
+ for (let cols = 1; cols <= Math.max(1, n); cols++) {
25
+ const rows = Math.ceil(n / cols);
26
+ const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
27
+ const byHeight = Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption;
28
+ const th = Math.min(Math.round(tw / aspect), byHeight);
29
+ if (th <= 0) continue;
30
+ options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
31
+ }
32
+ // The largest tiles, unless a grid with fewer empty slots keeps 85 % of their area.
33
+ const largest = Math.max(...options.map((o) => o.tw * o.th));
34
+ return options.filter((o) => o.tw * o.th >= 0.85 * largest).sort((a, b) => a.empty - b.empty || b.tw * b.th - a.tw * a.th)[0];
35
+ }
36
+
37
+ // The layout check as board rules: every gate row of the text QA that is not N/A.
38
+ export function boardRules(checks) {
39
+ return Object.entries(checks).filter(([, row]) => !row.na).map(([id, row]) => ({ id, status: row.pass ? "PASS" : row.warn ? "WARN" : "FAIL", detail: row.detail }));
40
+ }
41
+
42
+ // The board page's HTML for a treatment and its beat stills (file names relative to the page).
43
+ export function boardHtml({ treatment, stills, rules = null }) {
44
+ const style = treatment.style ? readStyle(treatment.style) : null;
45
+ const format = treatment.format === "9:16" ? "9:16" : "16:9";
46
+ const [width, height] = format === "9:16" ? [1080, 1920] : [1920, 1080];
47
+ const grid = boardGrid(stills.length, width, height);
48
+ const zones = LAYOUTS[format];
49
+ const pal = style?.palette ?? {};
50
+ const chips = Object.entries(pal).filter(([k]) => ["ground", "ink", "muted", "signal", "accent"].includes(k))
51
+ .map(([k, v]) => `<span class="chip"><i style="background:${esc(v)}"></i>${esc(k)} ${esc(v)}</span>`).join("");
52
+ const tiles = stills.map((file, i) => {
53
+ const b = treatment.beats[i] ?? {};
54
+ const z = b.layout && zones[b.layout]?.type;
55
+ const guide = z ? `<b class="zone" style="left:${(z[0] * 100).toFixed(2)}%;top:${(z[1] * 100).toFixed(2)}%;width:${(z[2] * 100).toFixed(2)}%;height:${(z[3] * 100).toFixed(2)}%"></b>` : "";
56
+ const meta = [String(i + 1).padStart(2, "0"), `${secs(b.t0)}–${secs(b.t1)} s`, b.layout, b.transition && b.transition !== "end" ? `→ ${b.transition}` : b.transition].filter(Boolean).map(esc).join(" · ");
57
+ return `<figure style="width:${grid.tw}px"><div class="frame" style="width:${grid.tw}px;height:${grid.th}px"><img src="${esc(file)}">${guide}</div>`
58
+ + `<figcaption><span class="meta">${meta}</span><span class="hero">${esc(b.hero ?? b.onScreen ?? "")}</span></figcaption></figure>`;
59
+ }).join("");
60
+ const failed = (rules ?? []).filter((x) => x.status === "FAIL");
61
+ const verdict = !rules ? "layout check: not run on this render"
62
+ : failed.length ? `layout check: FAIL — ${failed.map((x) => x.id).join(", ")}` : "layout check: PASS";
63
+ const sound = treatment.sound?.mode === "generated" && treatment.sound.palette
64
+ ? `${treatment.sound.palette}, ${treatment.sound.key}, ${treatment.sound.tempo} BPM`
65
+ : treatment.sound?.mode ?? "none";
66
+ const ground = pal.ground ?? "#101216";
67
+ return `<!doctype html>
68
+ <meta charset="utf-8">
69
+ <link rel="stylesheet" href="/lit/fonts.css">
70
+ <script src="/lit/stage-kit.js"></script>
71
+ <style>
72
+ html, body { margin: 0; width: ${BOARD.width}px; height: ${BOARD.height}px; overflow: hidden; background: #15171b; color: #e8e9e6; font-family: "Pretendard"; }
73
+ header { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: 36px; height: ${BOARD.header - 48}px; display: flex; justify-content: space-between; align-items: flex-start; }
74
+ h1 { margin: 0; font: 700 40px/1.1 "Pretendard"; }
75
+ .sub { margin-top: 10px; font: 400 20px/1.3 "MesloLGS NF"; color: #a8aba6; }
76
+ .style { text-align: right; }
77
+ .specimen { display: inline-block; padding: 6px 14px; margin-bottom: 10px; background: ${esc(ground)}; color: ${esc(pal.ink ?? "#fff")}; font: ${style?.faces.display.weight ?? 700} 34px/1.1 "${esc(style?.faces.display.family ?? "Pretendard")}", "Pretendard"; }
78
+ .chips { display: flex; gap: 14px; justify-content: flex-end; font: 400 16px/1 "MesloLGS NF"; color: #a8aba6; }
79
+ .chip i { display: inline-block; width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; border: 1px solid #555; }
80
+ main { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: ${BOARD.header}px; display: flex; flex-wrap: wrap; gap: ${BOARD.gap}px; }
81
+ figure { margin: 0; }
82
+ .frame { position: relative; overflow: hidden; background: #000; }
83
+ .frame img { display: block; width: 100%; height: 100%; }
84
+ .zone { position: absolute; box-sizing: border-box; border: 2px dashed rgba(255, 196, 0, 0.75); }
85
+ figcaption { display: block; height: ${BOARD.caption - 8}px; padding-top: 8px; overflow: hidden; }
86
+ .meta { display: block; font: 400 16px/1.3 "MesloLGS NF"; color: #c9cbc6; }
87
+ .hero { display: block; margin-top: 4px; font: 400 18px/1.35 "Pretendard"; color: #e8e9e6; }
88
+ footer { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; bottom: 18px; font: 400 18px/1.2 "MesloLGS NF"; color: ${failed.length ? "#ff8a7a" : "#9fd3a4"}; }
89
+ </style>
90
+ <body>
91
+ <header>
92
+ <div><h1>Storyboard · ${esc(treatment.subject?.name ?? "")}</h1>
93
+ <div class="sub">${esc(treatment.format)} · ${esc(treatment.durationSec)} s · ${treatment.beats.length} shots · ${esc(treatment.path)} path · sound: ${esc(sound)}</div></div>
94
+ <div class="style"><div class="specimen">${esc(style?.name ?? treatment.style ?? "")} Aa 가</div><div class="chips">${chips}</div></div>
95
+ </header>
96
+ <main>${tiles}</main>
97
+ <footer>${esc(verdict)} · dashed box: the shot's type zone</footer>
98
+ <script>LitStage.define({ width: ${BOARD.width}, height: ${BOARD.height}, fps: 30, duration: 1, render() {} });</script>
99
+ </body>
100
+ `;
101
+ }
102
+
103
+ // Draws `<out>/stills/board.png` from the beat stills already written. `context` is the stage context
104
+ // (Chrome path, faces); `rules` is the layout check when one ran. Returns the file path.
105
+ export async function writeBoard({ out, context, chromium, treatment, beatFiles, rules = null }) {
106
+ const dir = path.join(out, ".run", "board");
107
+ rmSync(dir, { recursive: true, force: true });
108
+ mkdirSync(dir, { recursive: true });
109
+ const names = beatFiles.map((file, i) => {
110
+ const name = `s${String(i + 1).padStart(2, "0")}.png`;
111
+ copyFileSync(file, path.join(dir, name));
112
+ return name;
113
+ });
114
+ writeFileSync(path.join(dir, "index.html"), boardHtml({ treatment, stills: names, rules }));
115
+ const stage = await openStage(chromium, { chromePath: context.chromePath, profile: path.join(out, ".run", "bd"), stageDir: dir, width: BOARD.width, height: BOARD.height, fps: 30, seed: 1, fonts: context.fonts });
116
+ try {
117
+ await stage.load(names.map((name) => `/${name}`));
118
+ await stage.step(0, true);
119
+ const file = path.join(out, "stills", "board.png");
120
+ writeFileSync(file, await stage.capture());
121
+ return file;
122
+ } finally {
123
+ await stage.close();
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ }
127
+
128
+ // Adds a file the renderer wrote after the stills set to `stills/stills.json`; returns the new index SHA-256.
129
+ export function addToStills(out, entry) {
130
+ const indexFile = path.join(out, "stills", "stills.json");
131
+ const index = JSON.parse(readFileSync(indexFile, "utf8"));
132
+ const file = path.join(out, entry.file);
133
+ if (!existsSync(file)) throw new Error(`addToStills: ${entry.file} was not written`);
134
+ index.files = [...index.files.filter((f) => f.file !== entry.file), { ...entry, sha256: sha256(readFileSync(file)) }];
135
+ const bytes = Buffer.from(`${JSON.stringify(index, null, 2)}\n`);
136
+ writeFileSync(indexFile, bytes);
137
+ return sha256(bytes);
138
+ }
@@ -1,9 +1,10 @@
1
1
  // The cover's asset list, `<out>/assets.json`. Every raster the cover page can load (base art, logo,
2
- // wordmark, texture) is listed with its path, role, source (user: supplied; generated: made for this
3
- // cover and labelled so in the reply) and SHA-256, and the renderer checks the list before the first
4
- // capture. The art is supplied or generated, never replaced by a drawn stand-in: a listed file that
5
- // is missing stops the run as BLOCKED_ASSET_MISSING (exit 22), and so does the painted-light recipe
6
- // with no base art listed. After the render, decoded frame 0 must show the base art (artCheck).
2
+ // wordmark, texture) is listed with its path, role, source (user: supplied; fetched: taken from the
3
+ // product's own pages by `render.mjs assets --cover`; generated: made for this cover and labelled so
4
+ // in the reply) and SHA-256, and the renderer checks the list before the first capture. The art is
5
+ // supplied, fetched or generated, never replaced by a drawn stand-in: a listed file that is missing
6
+ // stops the run as BLOCKED_ASSET_MISSING (exit 22), and so does the painted-light recipe with no
7
+ // user-supplied base art listed. After the render, decoded frame 0 must show the base art (artCheck).
7
8
  import { createHash } from "node:crypto";
8
9
  import { existsSync, readFileSync } from "node:fs";
9
10
  import path from "node:path";
@@ -35,7 +36,7 @@ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
35
36
  if (!entry || typeof entry !== "object") throw badEntry(at, "must be an object");
36
37
  if (typeof entry.path !== "string" || !entry.path.startsWith(`${coverDir}/`) || entry.path.split("/").includes("..")) throw badEntry(`${at}.path`, `must be a path inside ${coverDir}/, relative to the output directory`);
37
38
  if (!ASSET_ROLES.includes(entry.role)) throw badEntry(`${at}.role`, `must be one of ${ASSET_ROLES.join(", ")}`);
38
- if (!["user", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied) or generated");
39
+ if (!["user", "fetched", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied), fetched (by render.mjs assets --cover) or generated");
39
40
  if (!/^[0-9a-f]{64}$/u.test(entry.sha256 ?? "")) throw badEntry(`${at}.sha256`, "must be the file's SHA-256 (64 hex digits)");
40
41
  if ("box" in entry && !isBox(entry.box)) throw badEntry(`${at}.box`, "must be [x, y, w, h] as fractions of the cover, inside it");
41
42
  if (entry.role === "base-art" && !entry.box) throw badEntry(`${at}.box`, "base art needs the box it fills on the cover");
@@ -52,8 +53,10 @@ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
52
53
  if (sha256(readFileSync(real)) !== asset.sha256) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${asset.path} no longer matches its sha256 in assets.json; list it again after changing it`);
53
54
  }
54
55
  }
55
- if (treatment.recipe === "painted-light" && !assets.some((asset) => asset.role === "base-art")) {
56
- throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", "the painted-light recipe relights a painting, and assets.json lists no base-art; supply the painting or generate it and list it, never draw a stand-in");
56
+ // The painted recipe relights a painting the user supplied. Without one the cover takes hero-3d
57
+ // (or weight-tide, orbit-loop) instead of a stand-in painting drawn for the occasion.
58
+ if (treatment.recipe === "painted-light" && !assets.some((asset) => asset.role === "base-art" && asset.source === "user")) {
59
+ throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", "the painted-light recipe relights a painting, and assets.json lists no base-art with source user: a painting the user supplied; without one, take hero-3d (or weight-tide, orbit-loop), never draw or generate a stand-in");
57
60
  }
58
61
  const listed = new Set(assets.map((asset) => asset.path));
59
62
  for (const raster of rasters) {
@@ -1,4 +1,4 @@
1
- // One cover browser: the stage path's software rung and stage flags at device scale 2, the cover
1
+ // One cover browser: the stage path's software rung (the GPU rung for a 3D hero) and stage flags at device scale 2, the cover
2
2
  // page served from disk on the stage origin (engine/stage/capture.mjs serveStage, so no socket
3
3
  // listens), the loop kit at /lit/loop-kit.js, the product faces plus JetBrains Mono at
4
4
  // /lit/fonts.css, and the cover init script before any page script. The renderer SEEKS: it calls
@@ -23,9 +23,9 @@ const stageDir = path.join(here, "..", "stage");
23
23
  * software GL (canvas fillText, and DOM text at changing weights) came out one level apart on a
24
24
  * handful of pixels between them; CPU raster gives the same pixels every time.
25
25
  */
26
- export function coverChromeFlags(width, height, scale) {
27
- const flags = stageChromeFlags(width, height).map((flag) => (flag === "--force-device-scale-factor=1" ? `--force-device-scale-factor=${scale}` : flag));
28
- return [...flags, "--disable-gpu-rasterization", "--disable-partial-raster"];
26
+ export function coverChromeFlags(width, height, scale, rung = "swiftshader") {
27
+ const flags = stageChromeFlags(width, height, rung).map((flag) => (flag === "--force-device-scale-factor=1" ? `--force-device-scale-factor=${scale}` : flag));
28
+ return [...new Set([...flags, "--disable-gpu-rasterization", "--disable-partial-raster"])];
29
29
  }
30
30
 
31
31
  /** The stage faces plus JetBrains Mono, a variable face with a real wght axis (100-800). */
@@ -60,8 +60,8 @@ export async function waitForCoverReady(tab, pageFailed, timeout = 60000) {
60
60
  * Launch a cover browser on `coverDir` and load its index.html. `page` is the CSS size; screenshots
61
61
  * are `scale` times that. Returns { frame(t, { mask }), capture(), close(), flags }.
62
62
  */
63
- export async function openCover(chromium, { chromePath, profile, coverDir, page: [width, height], scale, fonts }) {
64
- const flags = coverChromeFlags(width, height, scale);
63
+ export async function openCover(chromium, { chromePath, profile, coverDir, page: [width, height], scale, fonts, rung = "swiftshader" }) {
64
+ const flags = coverChromeFlags(width, height, scale, rung);
65
65
  let context;
66
66
  try {
67
67
  context = await chromium.launchPersistentContext(profile, {
@@ -114,14 +114,27 @@ function prepare(out) {
114
114
  if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
115
115
  const chrome = findChrome();
116
116
  if (!chrome.path) throw stop(EXIT.BLOCKED_NO_CHROME, "BLOCKED_NO_CHROME", `${chrome.missing}; fix: install Chrome or set CHROME_PATH`);
117
- return { cover, coverDir, assets, state, chromePath: chrome.path, fonts: coverFonts(skillRoot, state, pins) };
117
+ return { cover, coverDir, assets, state, chromePath: chrome.path, fonts: coverFonts(skillRoot, state, pins), rung: cover.recipe === "hero-3d" ? "gpu" : "swiftshader", notices: [] };
118
118
  }
119
119
 
120
- // One browser for one pass; the profile lives under .run/cover/ and goes with the pass.
120
+ // One browser for one pass; the profile lives under .run/cover/ and goes with the pass. The 3D hero asks
121
+ // for the GPU rung. When a browser cannot start or draw there, every pass of the round takes the
122
+ // software rung (the passes must share one) and the report says so; a page that breaks the
123
+ // determinism or network rules is not retried, it would break the same way.
121
124
  async function withBrowser(chromium, context, profile, body) {
122
- rmSync(profile, { recursive: true, force: true });
123
- mkdirSync(profile, { recursive: true });
124
- const browser = await openCover(chromium, { chromePath: context.chromePath, profile, coverDir: context.coverDir, page: context.cover.page, scale: COVER.captureScale, fonts: context.fonts });
125
+ const open = async () => {
126
+ rmSync(profile, { recursive: true, force: true });
127
+ mkdirSync(profile, { recursive: true });
128
+ return openCover(chromium, { chromePath: context.chromePath, profile, coverDir: context.coverDir, page: context.cover.page, scale: COVER.captureScale, fonts: context.fonts, rung: context.rung });
129
+ };
130
+ let browser;
131
+ try { browser = await open(); }
132
+ catch (error) {
133
+ if (context.rung !== "gpu" || [EXIT.STAGE_NETWORK_REQUEST, EXIT.STAGE_NONDETERMINISTIC].includes(error.exitCode)) throw error;
134
+ context.notices.push(`the 3D hero asked for the GPU rung and it failed there (${String(error.message).slice(0, 120)}); rendered on the software rung (SwiftShader), slower, same frames`);
135
+ context.rung = "swiftshader";
136
+ browser = await open();
137
+ }
125
138
  try { return await body(browser); } finally { await browser.close(); rmSync(profile, { recursive: true, force: true }); }
126
139
  }
127
140
 
@@ -255,11 +268,11 @@ export async function renderCover(out, { round = 1, log = () => {} } = {}) {
255
268
  renameSync(encoded.file, delivered);
256
269
  const stills = writeCoverStills(out, { round, gate, cover });
257
270
  writeFileSync(path.join(out, "poster.png"), readFileSync(path.join(out, "stills", "poster.png")));
258
- const report = formatCoverReport(gate, { strict: true });
271
+ const report = `${formatCoverReport(gate, { strict: true })}${context.notices.map((note) => `warning: ${note}\n`).join("")}`;
259
272
  writeFileSync(path.join(out, "gate-report.txt"), report);
260
273
  const manifest = {
261
274
  schemaVersion: 1, mode: "cover", path: "cover", craftRound: round, recipe: cover.recipe,
262
- page: cover.page, size, scale: COVER.captureScale, fps: cover.fps, frames: times.length, frameMs: ms, seed: cover.seed,
275
+ page: cover.page, size, scale: COVER.captureScale, rung: context.rung, warnings: context.notices, fps: cover.fps, frames: times.length, frameMs: ms, seed: cover.seed,
263
276
  encoder: encoded.encoder, encode: cover.encode, file: path.basename(delivered), bytes: statSync(delivered).size, captureSec: Number(captureSec.toFixed(1)),
264
277
  treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), coverSha256: stageSha(context.coverDir), stillsSha256: stills.sha256,
265
278
  gate: { pass: gate.pass, target: gate.target, medianD: gate.medianD, movingFrac: gate.movingFrac, longestStillSec: gate.longestStillSec, spike: gate.spike, seamLocal: gate.seamLocal, gates: gate.gates },