@litfamily/litgrok 1.0.26 → 1.0.28

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 (235) hide show
  1. package/.grok/hooks/lit-mark.mjs +42 -0
  2. package/.grok/hooks/lit-status-line.mjs +174 -21
  3. package/.grok/hooks/plan-gate.mjs +13 -3
  4. package/.grok/hooks/record-passive-event.mjs +40 -6
  5. package/.grok/hooks/session-end.json +15 -0
  6. package/.grok/hooks/session-end.mjs +6 -0
  7. package/.grok/hooks/session-liveness.mjs +102 -0
  8. package/.grok/hooks/session-rows.mjs +57 -0
  9. package/.grok/hooks/session-start.mjs +17 -0
  10. package/.grok/hooks/workflow-stage.mjs +5 -2
  11. package/.grok/rules/00-litgrok.md +14 -0
  12. package/.grok/skills/frontend-ui-ux/ATTRIBUTION.md +23 -0
  13. package/.grok/skills/frontend-ui-ux/SKILL.md +4 -1
  14. package/.grok/skills/frontend-ui-ux/examples/phase-3-readme-ab/README.md +9 -0
  15. package/.grok/skills/frontend-ui-ux/kit/components.css +414 -0
  16. package/.grok/skills/frontend-ui-ux/kit/fonts.json +22 -0
  17. package/.grok/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  18. package/.grok/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  19. package/.grok/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  20. package/.grok/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  21. package/.grok/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  22. package/.grok/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  23. package/.grok/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  24. package/.grok/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  25. package/.grok/skills/frontend-ui-ux/kit/ui.js +226 -0
  26. package/.grok/skills/frontend-ui-ux/references/adaptive-layout.md +10 -4
  27. package/.grok/skills/frontend-ui-ux/references/brand-and-imagery.md +11 -7
  28. package/.grok/skills/frontend-ui-ux/references/complete-contract.md +21 -0
  29. package/.grok/skills/frontend-ui-ux/references/composition.md +10 -6
  30. package/.grok/skills/frontend-ui-ux/references/craft-floor.md +256 -93
  31. package/.grok/skills/frontend-ui-ux/references/creative-directions.md +9 -6
  32. package/.grok/skills/frontend-ui-ux/references/default-editorial-pixel.json +28 -0
  33. package/.grok/skills/frontend-ui-ux/references/house-styles.md +234 -0
  34. package/.grok/skills/frontend-ui-ux/references/implementation-platforms.md +10 -6
  35. package/.grok/skills/frontend-ui-ux/references/inclusive-interface.md +11 -5
  36. package/.grok/skills/frontend-ui-ux/references/interaction-motion.md +34 -78
  37. package/.grok/skills/frontend-ui-ux/references/motion-guide.md +10 -7
  38. package/.grok/skills/frontend-ui-ux/references/performance-delivery.md +11 -8
  39. package/.grok/skills/frontend-ui-ux/references/probe-loop.md +1 -1
  40. package/.grok/skills/frontend-ui-ux/references/product-direction.md +9 -4
  41. package/.grok/skills/frontend-ui-ux/references/production-interview.md +17 -0
  42. package/.grok/skills/frontend-ui-ux/references/redesign-playbook.md +10 -6
  43. package/.grok/skills/frontend-ui-ux/references/slop-register.md +148 -73
  44. package/.grok/skills/frontend-ui-ux/references/system-foundations.md +10 -4
  45. package/.grok/skills/frontend-ui-ux/references/visual-language.md +10 -4
  46. package/.grok/skills/frontend-ui-ux/references/visual-reconstruction.md +39 -72
  47. package/.grok/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  48. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-driver.mjs +33 -0
  49. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  50. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +1555 -0
  51. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +153 -0
  52. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-server.mjs +57 -0
  53. package/.grok/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +139 -0
  54. package/.grok/skills/frontend-ui-ux/scripts/interface-probe.mjs +363 -0
  55. package/.grok/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  56. package/.grok/skills/lit-adhd/SKILL.md +155 -0
  57. package/.grok/skills/lit-adhd/references/card-examples.md +131 -0
  58. package/.grok/skills/lit-adhd/references/status-taxonomy.md +50 -0
  59. package/.grok/skills/lit-adhd/scripts/lib/brief.mjs +421 -0
  60. package/.grok/skills/lit-adhd/scripts/lib/collect.mjs +395 -0
  61. package/.grok/skills/lit-adhd/scripts/lib/gauge.mjs +87 -0
  62. package/.grok/skills/lit-adhd/scripts/lib/next-step.mjs +22 -0
  63. package/.grok/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  64. package/.grok/skills/lit-adhd/scripts/lib/project-state-root.mjs +55 -0
  65. package/.grok/skills/lit-adhd/scripts/lib/redact.mjs +64 -0
  66. package/.grok/skills/lit-adhd/scripts/lib/suggest.mjs +140 -0
  67. package/.grok/skills/lit-adhd/scripts/lib/transcripts.mjs +232 -0
  68. package/.grok/skills/lit-adhd/scripts/rewind.mjs +6 -0
  69. package/.grok/skills/lit-diagram-drawer/SKILL.md +17 -5
  70. package/.grok/skills/lit-diagram-drawer/references/output-spec.md +2 -2
  71. package/.grok/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  72. package/.grok/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  73. package/.grok/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  74. package/.grok/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  75. package/.grok/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  76. package/.grok/skills/lit-diagram-drawer/references/verifier-guide.md +4 -0
  77. package/.grok/skills/lit-diagram-drawer/scripts/visual-quality.mjs +2 -2
  78. package/.grok/skills/lit-handoff/SKILL.md +2 -0
  79. package/.grok/skills/lit-hijack/SKILL.md +459 -0
  80. package/.grok/skills/lit-hijack/references/capture-and-provenance.md +124 -0
  81. package/.grok/skills/lit-hijack/references/clean-room-build.md +130 -0
  82. package/.grok/skills/lit-hijack/references/distil-and-licence.md +128 -0
  83. package/.grok/skills/lit-hijack/references/gap-and-port-design.md +150 -0
  84. package/.grok/skills/lit-hijack/references/parity-and-report.md +130 -0
  85. package/.grok/skills/lit-hijack/scripts/hijack.mjs +890 -0
  86. package/.grok/skills/lit-hijack/scripts/lib/safe-fetch.mjs +213 -0
  87. package/.grok/skills/lit-plan/SKILL.md +3 -1
  88. package/.grok/skills/lit-plan/references/plan-schema.md +4 -0
  89. package/.grok/skills/lit-plan/scripts/scaffold-plan.mjs +9 -2
  90. package/.grok/skills/lit-plan/scripts/scope-roots.mjs +83 -0
  91. package/.grok/skills/lit-pptx/SKILL.md +10 -5
  92. package/.grok/skills/lit-pptx/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  93. package/.grok/skills/lit-pptx/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  94. package/.grok/skills/lit-pptx/examples/15-design-lecture-bauhaus-ko.md +139 -0
  95. package/.grok/skills/lit-pptx/examples/16-community-programme-riso-ko.md +158 -0
  96. package/.grok/skills/lit-pptx/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  97. package/.grok/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  98. package/.grok/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  99. package/.grok/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  100. package/.grok/skills/lit-pptx/fonts/jetbrains-mono/provenance.json +1 -0
  101. package/.grok/skills/lit-pptx/references/direction-step.md +34 -8
  102. package/.grok/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  103. package/.grok/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  104. package/.grok/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  105. package/.grok/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  106. package/.grok/skills/lit-pptx/references/tonalities/riso.md +137 -0
  107. package/.grok/skills/lit-pptx/scripts/compile-deck.js +1 -1
  108. package/.grok/skills/lit-pptx/scripts/craft_extras.py +61 -3
  109. package/.grok/skills/lit-pptx/scripts/deck_output.py +76 -62
  110. package/.grok/skills/lit-pptx/scripts/embed_fonts.py +14 -1
  111. package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  112. package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  113. package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +389 -582
  114. package/.grok/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  115. package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +60 -8
  116. package/.grok/skills/lit-pptx/scripts/qa_deck.py +4 -4
  117. package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +2 -0
  118. package/.grok/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  119. package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +2 -0
  120. package/.grok/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  121. package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +2 -0
  122. package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -0
  123. package/.grok/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  124. package/.grok/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  125. package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +2 -0
  126. package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +2 -0
  127. package/.grok/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  128. package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +3 -0
  129. package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +2 -0
  130. package/.grok/skills/lit-recap/SKILL.md +1 -1
  131. package/.grok/skills/lit-schematic/SKILL.md +140 -0
  132. package/.grok/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  133. package/.grok/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  134. package/.grok/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  135. package/.grok/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  136. package/.grok/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  137. package/.grok/skills/lit-schematic/references/isometric-craft.md +101 -0
  138. package/.grok/skills/lit-schematic/references/journal-figures.md +72 -0
  139. package/.grok/skills/lit-schematic/references/kit-api.md +134 -0
  140. package/.grok/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  141. package/.grok/skills/lit-schematic/references/pid-drafting.md +103 -0
  142. package/.grok/skills/lit-schematic/references/pid-symbols.md +163 -0
  143. package/.grok/skills/lit-schematic/references/verification.md +100 -0
  144. package/.grok/skills/lit-schematic/scripts/doctor.mjs +57 -0
  145. package/.grok/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  146. package/.grok/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  147. package/.grok/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  148. package/.grok/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  149. package/.grok/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  150. package/.grok/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  151. package/.grok/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  152. package/.grok/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  153. package/.grok/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  154. package/.grok/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  155. package/.grok/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  156. package/.grok/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  157. package/.grok/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  158. package/.grok/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  159. package/.grok/skills/lit-schematic/scripts/motion.mjs +129 -0
  160. package/.grok/skills/lit-schematic/scripts/qa.mjs +92 -0
  161. package/.grok/skills/lit-schematic/scripts/render.mjs +233 -0
  162. package/.grok/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  163. package/.grok/skills/lit-scientific-visualization/SKILL.md +36 -12
  164. package/.grok/skills/lit-typographic-motion/SKILL.md +7 -4
  165. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +11 -0
  166. package/.grok/skills/lit-typographic-motion/recipes/hero-3d/expected.json +62 -0
  167. package/.grok/skills/lit-typographic-motion/recipes/hero-3d/index.html +82 -0
  168. package/.grok/skills/lit-typographic-motion/references/choice-first.md +30 -0
  169. package/.grok/skills/lit-typographic-motion/references/cover.md +10 -1
  170. package/.grok/skills/lit-typographic-motion/references/depth.md +119 -0
  171. package/.grok/skills/lit-typographic-motion/references/treatment.md +9 -1
  172. package/.grok/skills/lit-typographic-motion/scripts/blender/runner.py +218 -0
  173. package/.grok/skills/lit-typographic-motion/scripts/lib/assets.mjs +230 -0
  174. package/.grok/skills/lit-typographic-motion/scripts/lib/blender.mjs +136 -0
  175. package/.grok/skills/lit-typographic-motion/scripts/lib/board.mjs +153 -0
  176. package/.grok/skills/lit-typographic-motion/scripts/lib/chrome.mjs +61 -32
  177. package/.grok/skills/lit-typographic-motion/scripts/lib/constants.mjs +4 -2
  178. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-capture.mjs +20 -11
  179. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-treatment.mjs +26 -2
  180. package/.grok/skills/lit-typographic-motion/scripts/lib/cover.mjs +12 -5
  181. package/.grok/skills/lit-typographic-motion/scripts/lib/frame-pool.mjs +78 -1
  182. package/.grok/skills/lit-typographic-motion/scripts/lib/gallery.mjs +160 -0
  183. package/.grok/skills/lit-typographic-motion/scripts/lib/house.mjs +43 -0
  184. package/.grok/skills/lit-typographic-motion/scripts/lib/look.mjs +39 -1
  185. package/.grok/skills/lit-typographic-motion/scripts/lib/scaffold.mjs +174 -0
  186. package/.grok/skills/lit-typographic-motion/scripts/lib/score.mjs +640 -0
  187. package/.grok/skills/lit-typographic-motion/scripts/lib/sound.mjs +143 -0
  188. package/.grok/skills/lit-typographic-motion/scripts/lib/soundtrack.mjs +12 -3
  189. package/.grok/skills/lit-typographic-motion/scripts/lib/stage-gate.mjs +44 -0
  190. package/.grok/skills/lit-typographic-motion/scripts/lib/stage-qa.mjs +132 -3
  191. package/.grok/skills/lit-typographic-motion/scripts/lib/stage-session.mjs +51 -3
  192. package/.grok/skills/lit-typographic-motion/scripts/lib/stage.mjs +74 -9
  193. package/.grok/skills/lit-typographic-motion/scripts/lib/styles.mjs +224 -0
  194. package/.grok/skills/lit-typographic-motion/scripts/lib/treatment.mjs +198 -4
  195. package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/LICENSE +21 -0
  196. package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  197. package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  198. package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/three.module.js +54571 -0
  199. package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +56 -6
  200. package/.grok/skills/lit-typographic-motion/scripts/stage/cover-init.js +20 -8
  201. package/.grok/skills/lit-typographic-motion/scripts/stage/loop-kit.js +2 -2
  202. package/.grok/skills/lit-typographic-motion/scripts/stage/stage-kit.js +25 -0
  203. package/.grok/skills/lit-typographic-motion/styles/paper.json +1 -0
  204. package/.grok/skills/lit-typographic-motion/styles/swiss-signal.json +1 -0
  205. package/.grok/skills/lit-typographic-motion/styles/terminalcore.json +1 -0
  206. package/.grok/skills/lit-typographic-motion/styles/tidal.json +1 -0
  207. package/.grok/skills/litgrok/SKILL.md +1 -1
  208. package/.grok/skills/litresearch/SKILL.md +2 -0
  209. package/.grok/skills/readme-studio/SKILL.md +14 -12
  210. package/.grok/skills/readme-studio/references/choice-first.md +24 -0
  211. package/.grok/skills/start-work/SKILL.md +8 -1
  212. package/.grok-plugin/plugin.json +4 -1
  213. package/CHANGELOG.md +18 -0
  214. package/README.md +11 -11
  215. package/README_ko-KR.md +11 -11
  216. package/TRADEMARK.md +12 -0
  217. package/bin/litgrok.mjs +2 -0
  218. package/docs/assets/readme/badge-version.svg +1 -1
  219. package/docs/assets/showcase/brief.webp +0 -0
  220. package/docs/assets/showcase/deck.webp +0 -0
  221. package/docs/assets/showcase/page.webp +0 -0
  222. package/docs/assets/showcase/pid.webp +0 -0
  223. package/docs/assets/skills/lit-adhd.webp +0 -0
  224. package/docs/assets/skills/lit-hijack.webp +0 -0
  225. package/docs/assets/skills/lit-schematic.webp +0 -0
  226. package/docs/reference.md +11 -7
  227. package/docs/reference_ko-KR.md +11 -7
  228. package/hooks/hooks.json +11 -0
  229. package/package.json +6 -1
  230. package/plugin.json +1 -1
  231. package/.grok/skills/frontend-ui-ux/scripts/page-probe.mjs +0 -105
  232. package/.grok/skills/frontend-ui-ux/scripts/probe.mjs +0 -91
  233. package/.grok/skills/frontend-ui-ux/scripts/rule-data.mjs +0 -56
  234. package/.grok/skills/frontend-ui-ux/scripts/static-probe.mjs +0 -27
  235. package/.grok/skills/frontend-ui-ux/scripts/static-server.mjs +0 -19
@@ -0,0 +1,153 @@
1
+ // The storyboard: `stills/board.png`, one page a person can read before the full render.
2
+ // One tile per beat (the beat still) with the shot's number, time, layout and hand-off under it
3
+ // and the shot's type zone outlined on the frame when the beat names one; a header with the house
4
+ // style (name, palette, type) and the film's format, length and sound; a footer with the layout
5
+ // check. Drawn as a page in the same headless Chrome the stage uses, so its type is product faces.
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { LAYOUTS, readStyle } from "./styles.mjs";
10
+ import { resolveChrome } from "./chrome.mjs";
11
+ import { openStage } from "./stage-session.mjs";
12
+ import { stageFonts } from "./stage.mjs";
13
+
14
+ const SKILL_ROOT = fileURLToPath(new URL("../..", import.meta.url));
15
+
16
+ export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
17
+
18
+ const esc = (text) => String(text ?? "").replace(/[&<>"]/gu, (c) => ({ "&": "&amp;", "<": "&gt;", '"': "&quot;" }[c] ?? c));
19
+ const secs = (v) => `${Number(v).toFixed(1)}`;
20
+
21
+ /** Tile geometry: columns and thumbnail size for n shots of a film of size width x height. */
22
+ export function boardGrid(n, width, height) {
23
+ const usableW = BOARD.width - 2 * BOARD.margin;
24
+ const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
25
+ const aspect = width / height;
26
+ const options = [];
27
+ for (let cols = 1; cols <= Math.max(1, n); cols++) {
28
+ const rows = Math.ceil(n / cols);
29
+ const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
30
+ const byHeight = Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption;
31
+ const th = Math.min(Math.round(tw / aspect), byHeight);
32
+ if (th <= 0) continue;
33
+ options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
34
+ }
35
+ // The largest tiles, unless a grid with fewer empty slots keeps 85 % of their area.
36
+ const largest = Math.max(...options.map((o) => o.tw * o.th));
37
+ 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];
38
+ }
39
+
40
+ /** The board page's HTML for a treatment and its beat stills (file names relative to the page). */
41
+ export function boardHtml({ treatment, stills, check = null }) {
42
+ const style = treatment.style ? readStyle(treatment.style) : null;
43
+ const [width, height] = treatment.format === "9:16" ? [1080, 1920] : [1920, 1080];
44
+ const grid = boardGrid(stills.length, width, height);
45
+ const zones = LAYOUTS[treatment.format === "9:16" ? "9:16" : "16:9"];
46
+ const pal = style?.palette ?? {};
47
+ const chips = Object.entries(pal).filter(([k]) => ["ground", "ink", "muted", "signal", "accent"].includes(k))
48
+ .map(([k, v]) => `<span class="chip"><i style="background:${esc(v)}"></i>${esc(k)} ${esc(v)}</span>`).join("");
49
+ const tiles = stills.map((file, i) => {
50
+ const b = treatment.beats[i] ?? {};
51
+ const z = b.layout && zones[b.layout]?.type;
52
+ 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>` : "";
53
+ const handoff = b.transition && b.transition !== "end" ? `→ ${b.transition}` : b.transition;
54
+ const meta = [String(i + 1).padStart(2, "0"), `${secs(b.t0)}–${secs(b.t1)} s`, b.layout, handoff].filter(Boolean).map(esc).join(" · ");
55
+ 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>`
56
+ + `<figcaption><span class="meta">${meta}</span><span class="hero">${esc(b.hero ?? b.onScreen ?? "")}</span></figcaption></figure>`;
57
+ }).join("");
58
+ const failed = (check?.rules ?? []).filter((x) => x.status === "FAIL");
59
+ const verdict = !check ? "layout check: not run on this render"
60
+ : failed.length ? `layout check: FAIL — ${failed.map((x) => x.id).join(", ")}` : "layout check: PASS";
61
+ const sound = treatment.sound?.mode === "generated" && typeof treatment.sound.palette === "object"
62
+ ? `${treatment.sound.palette.timbre}, ${treatment.sound.palette.key} ${treatment.sound.palette.mode}, ${treatment.sound.palette.tempo} BPM`
63
+ : treatment.sound?.mode === "score" ? `score (${treatment.sound.genre ?? style?.score?.genre ?? "house"})` : treatment.sound?.mode ?? "none";
64
+ const ground = pal.ground ?? "#101216";
65
+ return `<!doctype html>
66
+ <meta charset="utf-8">
67
+ <link rel="stylesheet" href="/lit/fonts.css">
68
+ <link rel="stylesheet" href="/lit/style.css">
69
+ <script src="/lit/stage-kit.js"></script>
70
+ <style>
71
+ html, body { margin: 0; width: ${BOARD.width}px; height: ${BOARD.height}px; overflow: hidden; background: #15171b; color: #e8e9e6; font-family: "Pretendard"; }
72
+ 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; }
73
+ h1 { margin: 0; font: 700 40px/1.1 "Pretendard"; }
74
+ .sub { margin-top: 10px; font: 400 20px/1.3 "MesloLGS NF"; color: #a8aba6; }
75
+ .style { text-align: right; }
76
+ .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"; }
77
+ .chips { display: flex; gap: 14px; justify-content: flex-end; font: 400 16px/1 "MesloLGS NF"; color: #a8aba6; }
78
+ .chip i { display: inline-block; width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; border: 1px solid #555; }
79
+ main { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: ${BOARD.header}px; display: flex; flex-wrap: wrap; gap: ${BOARD.gap}px; }
80
+ figure { margin: 0; }
81
+ .frame { position: relative; overflow: hidden; background: #000; }
82
+ .frame img { display: block; width: 100%; height: 100%; }
83
+ .zone { position: absolute; box-sizing: border-box; border: 2px dashed rgba(255, 196, 0, 0.75); }
84
+ figcaption { display: block; height: ${BOARD.caption - 8}px; padding-top: 8px; overflow: hidden; }
85
+ .meta { display: block; font: 400 16px/1.3 "MesloLGS NF"; color: #c9cbc6; }
86
+ .hero { display: block; margin-top: 4px; font: 400 18px/1.35 "Pretendard"; color: #e8e9e6; }
87
+ 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"}; }
88
+ </style>
89
+ <body>
90
+ <header>
91
+ <div><h1>Storyboard · ${esc(treatment.subject?.name ?? "")}</h1>
92
+ <div class="sub">${esc(treatment.format)} · ${esc(treatment.durationSec)} s · ${treatment.beats.length} shots · ${esc(treatment.path)} path · sound: ${esc(sound)}</div></div>
93
+ <div class="style"><div class="specimen">${esc(style?.name ?? treatment.style ?? "")} Aa 가</div><div class="chips">${chips}</div></div>
94
+ </header>
95
+ <main>${tiles}</main>
96
+ <footer>${esc(verdict)} · dashed box: the shot's type zone</footer>
97
+ <script>LitStage.define({ width: ${BOARD.width}, height: ${BOARD.height}, fps: 30, duration: 1, render() {} });</script>
98
+ </body>
99
+ `;
100
+ }
101
+
102
+ /**
103
+ * Render `<out>/stills/board.png` from the beat stills already written. Returns the file path.
104
+ * `check` is the layout check ({ rules }) when one ran.
105
+ */
106
+ export async function writeBoard({ out, treatment, beatFiles, check = null, env = process.env, log = () => {} }) {
107
+ const executable = resolveChrome(env);
108
+ if (!executable) throw new Error("writeBoard needs Chrome: set CHROME_PATH");
109
+ const dir = path.join(out, ".run", "board");
110
+ rmSync(dir, { recursive: true, force: true });
111
+ mkdirSync(dir, { recursive: true });
112
+ const names = beatFiles.map((f, i) => {
113
+ const name = `s${String(i + 1).padStart(2, "0")}.png`;
114
+ copyFileSync(f, path.join(dir, name));
115
+ return name;
116
+ });
117
+ writeFileSync(path.join(dir, "index.html"), boardHtml({ treatment, stills: names, check }));
118
+ const faces = [...new Set([...(treatment.typePlan?.faces ?? ["Pretendard"]), "Pretendard"])];
119
+ let style = null;
120
+ if (treatment.style) {
121
+ const { readStyle, styleCss, styleScript } = await import("./styles.mjs");
122
+ const declared = readStyle(treatment.style);
123
+ style = { css: styleCss(declared, treatment.format), js: styleScript(declared, treatment.format, treatment) };
124
+ }
125
+ const session = await openStage({
126
+ executable, outDir: out, stageDir: dir, width: BOARD.width, height: BOARD.height,
127
+ fps: 30, seed: 1, fonts: stageFonts(faces, env), style, label: "b", log,
128
+ });
129
+ try {
130
+ await session.load();
131
+ await session.step(0, 30);
132
+ const png = await session.capture();
133
+ const file = path.join(out, "stills", "board.png");
134
+ mkdirSync(path.join(out, "stills"), { recursive: true });
135
+ writeFileSync(file, png);
136
+ return file;
137
+ } finally {
138
+ await session.close();
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ /** Add a renderer-written file to stills/index.json; returns the new index SHA-256. */
144
+ export function addToStills(out, entry, { sha256 }) {
145
+ const indexFile = path.join(out, "stills", "index.json");
146
+ const index = JSON.parse(readFileSync(indexFile, "utf8"));
147
+ const file = path.join(out, entry.file);
148
+ if (!existsSync(file)) throw new Error(`addToStills: ${entry.file} was not written`);
149
+ index.stills = [...(index.stills ?? []).filter((f) => f.file !== entry.file), { ...entry, sha256: sha256(readFileSync(file)) }];
150
+ const bytes = Buffer.from(`${JSON.stringify(index, null, 2)}\n`);
151
+ writeFileSync(indexFile, bytes);
152
+ return sha256(bytes);
153
+ }
@@ -194,13 +194,18 @@ export async function openRenderer({ executable, profileDir, softwareOnly = fals
194
194
  export const STAGE_FLAGS = Object.freeze(['--run-all-compositor-stages-before-draw', '--disable-checker-imaging', '--disable-new-content-rendering-timeout', '--disable-threaded-animation', '--disable-threaded-scrolling', '--disable-image-animation-resync', '--disable-lcd-text', '--force-color-profile=srgb', '--hide-scrollbars', '--mute-audio', '--force-device-scale-factor=1', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--no-pings', '--metrics-recording-only']);
195
195
 
196
196
  // `scale` is the device scale: 1 for a film; a cover is captured at 2 and shrunk afterwards.
197
- // `extra` flags ride after the stage set (a cover adds COVER_FLAGS).
198
- export function stageArgs({ profileDir, width, height, scale = 1, extra = [] }) {
197
+ // `extra` flags ride after the stage set (a cover adds COVER_FLAGS). The gpu rung uses the
198
+ // platform's GL flags with page raster on the CPU; anything else is the SwiftShader rung.
199
+ export function stageArgs({ profileDir, width, height, scale = 1, extra = [], rung = 'software', platform = osPlatform() }) {
199
200
  const flags = STAGE_FLAGS.map((flag) => (flag === '--force-device-scale-factor=1' ? `--force-device-scale-factor=${scale}` : flag));
200
- const args = [...HOUSEKEEPING, `--user-data-dir=${profileDir}`, ...SWIFTSHADER_RUNG, ...COMMON_FLAGS, ...flags, `--window-size=${width},${height}`, `--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${STAGE.host}`, ...extra];
201
+ const rungFlags = rung === 'gpu' ? (gpuRungs(platform)[0] ?? []).filter((flag) => flag !== '--enable-gpu-rasterization') : SWIFTSHADER_RUNG;
202
+ const args = [...HOUSEKEEPING, `--user-data-dir=${profileDir}`, ...rungFlags, ...COMMON_FLAGS, ...flags, `--window-size=${width},${height}`, `--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${STAGE.host}`, ...extra];
201
203
  return [...new Set(args), 'about:blank'];
202
204
  }
203
205
 
206
+ // The rung counts only when the page gets a WebGL2 context; the renderer string is recorded.
207
+ const WEBGL_PROBE = `(() => { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2'); if (!gl) return { webgl2: false }; const info = gl.getExtension('WEBGL_debug_renderer_info'); return { webgl2: true, renderer: info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : ${JSON.stringify(UNKNOWN_RENDERER)} }; })()`;
208
+
204
209
  // Launch one stage Chrome and attach a page target. Exit 10 when Chrome is missing or never
205
210
  // answers; WebGL is not probed here (exit 11 applies only when the page asks for it).
206
211
  // A cover's frames are compared across fresh browsers and sought out of order. With GPU raster on
@@ -209,39 +214,63 @@ export function stageArgs({ profileDir, width, height, scale = 1, extra = [] })
209
214
  // frame identically in 5 of 5 pairs.
210
215
  export const COVER_FLAGS = Object.freeze(['--disable-gpu-rasterization']);
211
216
 
212
- export async function launchStage({ executable, profileDir, width, height, scale = 1, extra = [], timeoutMs = 30000 }) {
217
+ // A 3D film asks for the gpu rung; when it cannot start or gives the page no WebGL2, the launch
218
+ // falls back to the software rung and records why. Either way the launch reports the rung the page
219
+ // got and its renderer string; without WebGL2 on any rung it is exit 11.
220
+ export async function launchStage({ executable, profileDir, width, height, scale = 1, extra = [], timeoutMs = 30000, rung = 'software', platform = osPlatform() }) {
213
221
  if (!executable) throw new BlockedError(EXIT.NO_CHROME, 'BLOCKED_NO_CHROME: Chrome/Chromium not found; set CHROME_PATH to a Chrome executable');
214
222
  mkdirSync(profileDir, { recursive: true });
215
- const args = stageArgs({ profileDir, width, height, scale, extra });
216
- let stderr = '';
217
- let proc;
218
- try { proc = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] }); } catch (error) { throw new BlockedError(EXIT.NO_CHROME, `BLOCKED_NO_CHROME: ${error.message}`); }
219
- proc.stderr.setEncoding('utf8');
220
- proc.stderr.on('data', (chunk) => { if (stderr.length < 16384) stderr += chunk; });
221
- const spawnError = new Promise((_, reject) => proc.once('error', (error) => reject(error)));
222
- const session = new PipeSession(proc);
223
- const close = async () => {
224
- if (proc.exitCode === null && proc.signalCode === null) {
225
- try { await session.send('Browser.close', {}, undefined, 5000); } catch {}
226
- await new Promise((resolve) => {
227
- const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} resolve(); }, 5000);
228
- proc.once('exit', () => { clearTimeout(timer); resolve(); });
229
- if (proc.exitCode !== null || proc.signalCode !== null) { clearTimeout(timer); resolve(); }
230
- });
223
+ // Strict only for the gpu rung: a 3D page needs its WebGL context, while a 2D page renders
224
+ // without one (the renderer is then unknown, and the software label still holds).
225
+ const attempt = async (wanted, strict) => {
226
+ const args = stageArgs({ profileDir, width, height, scale, extra, rung: wanted, platform });
227
+ let stderr = '';
228
+ let proc;
229
+ try { proc = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] }); } catch (error) { throw new BlockedError(EXIT.NO_CHROME, `BLOCKED_NO_CHROME: ${error.message}`); }
230
+ proc.stderr.setEncoding('utf8');
231
+ proc.stderr.on('data', (chunk) => { if (stderr.length < 16384) stderr += chunk; });
232
+ const spawnError = new Promise((_, reject) => proc.once('error', (error) => reject(error)));
233
+ const session = new PipeSession(proc);
234
+ const close = async () => {
235
+ if (proc.exitCode === null && proc.signalCode === null) {
236
+ try { await session.send('Browser.close', {}, undefined, 5000); } catch {}
237
+ await new Promise((resolve) => {
238
+ const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} resolve(); }, 5000);
239
+ proc.once('exit', () => { clearTimeout(timer); resolve(); });
240
+ if (proc.exitCode !== null || proc.signalCode !== null) { clearTimeout(timer); resolve(); }
241
+ });
242
+ }
243
+ // Chrome's helpers can still be flushing the profile for a moment after the browser exits.
244
+ for (let retry = 0; retry < 20; retry += 1) {
245
+ try { rmSync(profileDir, { recursive: true, force: true }); break; } catch { await new Promise((resolve) => setTimeout(resolve, 100)); }
246
+ }
247
+ };
248
+ try {
249
+ await Promise.race([spawnError, session.send('Browser.getVersion', {}, undefined, timeoutMs)]);
250
+ } catch (error) {
251
+ await close();
252
+ throw new BlockedError(EXIT.NO_CHROME, `BLOCKED_NO_CHROME: Chrome failed to launch headless: ${firstLine(stderr) || error.message}`);
231
253
  }
232
- // Chrome's helpers can still be flushing the profile for a moment after the browser exits.
233
- for (let attempt = 0; attempt < 20; attempt += 1) {
234
- try { rmSync(profileDir, { recursive: true, force: true }); break; } catch { await new Promise((resolve) => setTimeout(resolve, 100)); }
254
+ const { targetId } = await session.send('Target.createTarget', { url: 'about:blank' });
255
+ const { sessionId } = await session.send('Target.attachToTarget', { targetId, flatten: true });
256
+ const send = (method, params = {}, timeout) => session.send(method, params, sessionId, timeout);
257
+ await send('Runtime.enable').catch(() => {});
258
+ const probe = await send('Runtime.evaluate', { expression: WEBGL_PROBE, returnByValue: true }, timeoutMs).catch(() => null);
259
+ const found = probe?.result?.value;
260
+ if (!found?.webgl2) {
261
+ if (!strict) return { browser: { proc, session, sessionId, send, close, args, on: (listener) => session.listeners.add(listener) }, renderer: UNKNOWN_RENDERER };
262
+ await close();
263
+ return { webgl2: false, detail: firstLine(stderr) };
235
264
  }
265
+ return { browser: { proc, session, sessionId, send, close, args, on: (listener) => session.listeners.add(listener) }, renderer: found.renderer ?? UNKNOWN_RENDERER };
236
266
  };
237
- try {
238
- await Promise.race([spawnError, session.send('Browser.getVersion', {}, undefined, timeoutMs)]);
239
- } catch (error) {
240
- await close();
241
- throw new BlockedError(EXIT.NO_CHROME, `BLOCKED_NO_CHROME: Chrome failed to launch headless: ${firstLine(stderr) || error.message}`);
267
+ const first = await attempt(rung, rung === 'gpu');
268
+ if (first.browser) return { ...first.browser, rung, renderer: first.renderer, notice: null };
269
+ if (rung === 'gpu') {
270
+ const fallback = await attempt('software', false);
271
+ if (fallback.browser) {
272
+ return { ...fallback.browser, rung: 'software', renderer: fallback.renderer, notice: `the 3D route asked for the GPU rung and it gave the page no WebGL there${first.detail ? ` (${first.detail})` : ''}; rendered on the software rung (SwiftShader), slower, same frames` };
273
+ }
242
274
  }
243
- const { targetId } = await session.send('Target.createTarget', { url: 'about:blank' });
244
- const { sessionId } = await session.send('Target.attachToTarget', { targetId, flatten: true });
245
- const send = (method, params = {}, timeout) => session.send(method, params, sessionId, timeout);
246
- return { proc, session, sessionId, send, close, args, on: (listener) => session.listeners.add(listener) };
275
+ throw new BlockedError(EXIT.NO_WEBGL2, `BLOCKED_NO_WEBGL2: Chrome ran but no rung produced a WebGL2 context${first.detail ? ` (${first.detail})` : ''}`);
247
276
  }
@@ -171,11 +171,13 @@ export const DIRECTOR = Object.freeze({
171
171
  durationRange: Object.freeze([4, 90]),
172
172
  minBeatSec: 1.2,
173
173
  maxGapSec: 0.25,
174
- deviceKinds: Object.freeze(['illustration', 'diagram', 'chart', 'icon', 'shape', 'path', 'mask', 'depth3d', 'particles', 'grid', 'gradient', 'photo-texture']),
174
+ deviceKinds: Object.freeze(['illustration', 'diagram', 'chart', 'icon', 'shape', 'path', 'mask', 'depth3d', 'screen', 'particles', 'grid', 'gradient', 'photo-texture']),
175
+ protagonistKinds: Object.freeze(['character', 'object']),
176
+ assetRoles: Object.freeze(['logo', 'wordmark', 'icon', 'photo', 'screenshot', 'ui', 'readme-image']),
175
177
  textureKinds: Object.freeze(['grid', 'gradient', 'particles', 'photo-texture']),
176
178
  subjectCoverage: 0.5,
177
179
  faces: Object.freeze(['Archivo', 'Pretendard', 'VT323', 'Silkscreen', 'Galmuri9', 'Meslo']),
178
- soundModes: Object.freeze(['generated', 'supplied', 'authored', 'none']),
180
+ soundModes: Object.freeze(['score', 'generated', 'supplied', 'authored', 'none']),
179
181
  timbres: Object.freeze(['glass', 'felt', 'pulse']),
180
182
  });
181
183
 
@@ -1,17 +1,17 @@
1
- // Cover capture: one stage Chrome (the software rung and every stage flag, so a mismatch is always
2
- // the page's doing) laid out at the cover's CSS size and captured at device scale 2. The page is
3
- // served from <out>/cover/ on the synthetic stage origin through CDP Fetch interception, beside the
4
- // /lit/ routes (fonts, the loop kit), and the cover init script runs before any page script. The
5
- // renderer SEEKS: it calls window.renderFrame(t, { mask }) for any t in [0, 1) in any order, so the
6
- // master pass, the replay in a fresh browser, the seek-order pass and a window re-render each open
7
- // their own page here.
1
+ // Cover capture: one stage Chrome (the rung the recipe asks for, every stage flag, so a mismatch is
2
+ // always the page's doing) laid out at the cover's CSS size and captured at device scale 2. The
3
+ // page is served from <out>/cover/ on the synthetic stage origin through CDP Fetch interception,
4
+ // beside the /lit/ routes (fonts, the loop kit, the vendored three.js), and the cover init script
5
+ // runs before any page script. The renderer SEEKS: it calls window.renderFrame(t, { mask }) for any
6
+ // t in [0, 1) in any order, so the master pass, the replay in a fresh browser, the seek-order pass
7
+ // and a window re-render each open their own page here.
8
8
  import { randomBytes } from 'node:crypto';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { BlockedError, EXIT, STAGE } from './constants.mjs';
12
12
  import { COVER_FLAGS, launchStage, resolveChrome } from './chrome.mjs';
13
13
  import { SKILL_ROOT } from './fonts.mjs';
14
- import { CONTENT_TYPES, resolveStagePath } from './stage-session.mjs';
14
+ import { CONTENT_TYPES, resolveStagePath, resolveThree } from './stage-session.mjs';
15
15
 
16
16
  export const LOOP_KIT_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'loop-kit.js');
17
17
  export const COVER_INIT_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'cover-init.js');
@@ -19,11 +19,12 @@ export const COVER_INIT_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'cover-init.
19
19
  const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
20
20
 
21
21
  // Open one cover page and wait for window.ready. `dir` is the folder served as the site root
22
- // (<out>/cover for a render; the sheet's own folder for the contact sheet).
23
- export async function openCover({ dir, fonts, width, height, scale, profileRoot, env = process.env, label = 'c', log = () => {} }) {
22
+ // (<out>/cover for a render; the sheet's own folder for the contact sheet). A hero-3d cover asks
23
+ // for the GPU rung; anything else renders on software, where a mismatch is always the page's doing.
24
+ export async function openCover({ dir, fonts, width, height, scale, profileRoot, env = process.env, label = 'c', rung = 'software', log = () => {} }) {
24
25
  const executable = resolveChrome(env);
25
26
  if (!executable) throw new BlockedError(EXIT.NO_CHROME, 'BLOCKED_NO_CHROME: Chrome/Chromium not found; set CHROME_PATH to a Chrome executable');
26
- const browser = await launchStage({ executable, profileDir: join(profileRoot, `${label}-${randomBytes(3).toString('hex')}`), width, height, scale, extra: COVER_FLAGS });
27
+ const browser = await launchStage({ executable, profileDir: join(profileRoot, `${label}-${randomBytes(3).toString('hex')}`), width, height, scale, extra: COVER_FLAGS, rung });
27
28
  const network = [];
28
29
  const refused = [];
29
30
  const errors = [];
@@ -46,6 +47,13 @@ export async function openCover({ dir, fonts, width, height, scale, profileRoot,
46
47
  return;
47
48
  }
48
49
  if (url.pathname === '/lit/loop-kit.js') { await respond(requestId, 200, kit, CONTENT_TYPES['.js']); return; }
50
+ if (url.pathname.startsWith('/lit/three/')) {
51
+ const resolved = resolveThree(url.pathname);
52
+ if (resolved.refused) { refused.push(resolved.refused); await respond(requestId, 403, 'refused'); return; }
53
+ if (resolved.missing) { await respond(requestId, 404, 'not a vendored three.js file'); return; }
54
+ await respond(requestId, 200, readFileSync(resolved.file), resolved.type);
55
+ return;
56
+ }
49
57
  if (url.pathname === '/lit/fonts.css') { await respond(requestId, 200, fonts.css, CONTENT_TYPES['.css']); return; }
50
58
  if (url.pathname.startsWith('/lit/fonts/')) {
51
59
  const route = fonts.routes.get(url.pathname);
@@ -103,6 +111,7 @@ export async function openCover({ dir, fonts, width, height, scale, profileRoot,
103
111
 
104
112
  return {
105
113
  width, height, scale,
114
+ rung: browser.rung, renderer: browser.renderer, notice: browser.notice,
106
115
  // Draw loop time t (or its title mask), then wait for fonts and two native frames.
107
116
  async frame(t, { mask = false } = {}) {
108
117
  try {
@@ -7,9 +7,9 @@
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { BlockedError, COVER, EXIT } from './constants.mjs';
10
- import { normalize, restateLimit, sentenceCount, sharedRun, stripQuoted } from './treatment.mjs';
10
+ import { normalize, normUrl, requestUrls, restateLimit, sentenceCount, sharedRun, stripQuoted } from './treatment.mjs';
11
11
 
12
- export const RECIPES = Object.freeze(['painted-light', 'weight-tide', 'orbit-loop', 'own']);
12
+ export const RECIPES = Object.freeze(['hero-3d', 'painted-light', 'weight-tide', 'orbit-loop', 'own']);
13
13
  export const LAYERS = Object.freeze(['slow', 'mid', 'beat']);
14
14
 
15
15
  const PLACEHOLDER = /<[^<>\n]{1,80}>|^\s*(?:todo|tbd|tk|xxx|\.\.\.|…)\s*$/iu;
@@ -76,6 +76,19 @@ export function validateCoverTreatment(t) {
76
76
  }
77
77
  }
78
78
  if ('seed' in t && !Number.isInteger(t.seed)) return bad('seed', 'seed must be an integer');
79
+ // A link in the request names the product: the cover is built from its real assets, fetched first.
80
+ {
81
+ const urls = requestUrls(t.request);
82
+ if (!('product' in t) || t.product === null) {
83
+ if (urls.length) return bad('product', `product is missing: the request links ${urls[0]}; fetch its real assets first with motion.mjs assets --out <dir> --url <link> --cover, look at them, and set product { name, url }`);
84
+ } else {
85
+ const product = t.product;
86
+ if (!product || typeof product !== 'object') return bad('product', 'product must be an object { name, url }');
87
+ if (typeof product.name !== 'string' || !product.name.trim() || PLACEHOLDER.test(product.name)) return bad('product.name', 'product.name names the product');
88
+ if (urls.length && !urls.includes(normUrl(product.url ?? ''))) return bad('product.url', `product.url must be the link the request gives (${urls.join(', ')})`);
89
+ if ('unreachable' in product && product.unreachable !== undefined && product.unreachable !== null && (typeof product.unreachable !== 'string' || !product.unreachable.trim())) return bad('product.unreachable', 'product.unreachable says what could not be reached and why');
90
+ }
91
+ }
79
92
  const ambition = prose(t, 'ambition', 2);
80
93
  if (ambition) return ambition;
81
94
  const { page, out } = COVER.aspects[t.aspect];
@@ -95,6 +108,17 @@ export function loadCoverTreatment(outDir) {
95
108
  if (parsed?.mode !== 'cover') throw new BlockedError(EXIT.TREATMENT_INVALID, 'BLOCKED_TREATMENT_INVALID field=mode: this treatment is a film; render it with stage or make (references/treatment.md); a cover sets mode: cover');
96
109
  const result = validateCoverTreatment(parsed);
97
110
  if (!result.ok) throw new BlockedError(EXIT.TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID field=${result.field}: ${result.message}`);
111
+ // A linked cover is built only from fetched assets: product.json must exist, list cover files,
112
+ // and an unreachable product stops with exit 22 until the user supplies the art.
113
+ const coverProduct = result.treatment.product;
114
+ if (coverProduct?.unreachable) throw new BlockedError(EXIT.ASSET_MISSING, `BLOCKED_ASSET_MISSING: ${coverProduct.name}'s real assets could not be reached (${coverProduct.unreachable}); ask the user for its logo or README images, put them in ${join(outDir, 'cover', 'assets')} and list them in assets.json`);
115
+ if (coverProduct && !existsSync(join(outDir, 'product.json'))) throw new BlockedError(EXIT.TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID field=product: no ${join(outDir, 'product.json')}: run motion.mjs assets --out ${outDir} --url ${coverProduct.url} --cover first`);
116
+ if (coverProduct) {
117
+ let fetched;
118
+ try { fetched = JSON.parse(readFileSync(join(outDir, 'product.json'), 'utf8'))?.product?.assets ?? []; } catch (error) { throw new BlockedError(EXIT.TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID field=product: ${join(outDir, 'product.json')} is not valid JSON (${error.message}); run motion.mjs assets --out ${outDir} --url ${coverProduct.url} --cover again`); }
119
+ const astray = fetched.filter((asset) => !String(asset?.path ?? '').startsWith('cover/assets/'));
120
+ if (astray.length) throw new BlockedError(EXIT.TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID field=product: ${astray[0].path} sits outside cover/assets/, where the cover page cannot load it; run motion.mjs assets --out ${outDir} --url ${coverProduct.url} --cover again`);
121
+ }
98
122
  const first = join(outDir, '.run', 'treatment-first.json');
99
123
  if (!existsSync(first)) {
100
124
  mkdirSync(join(outDir, '.run'), { recursive: true });
@@ -210,17 +210,24 @@ export async function renderCover({ out, round = 1, env = process.env, log = (li
210
210
  const order = seekOrder(times.length, treatment.seed);
211
211
  const pool = new CoverPool();
212
212
  let master, replay, sought;
213
+ // A 3D hero turns on the GPU rung; when it cannot start there it falls back to software, and
214
+ // the report says so. The replay and the seek share the master's rung.
215
+ const rung = treatment.recipe === 'hero-3d' ? 'gpu' : 'software';
216
+ let rungUsed = rung, rendererUsed = null;
213
217
  try {
214
- const open = (label) => openCover({ dir, fonts, width: pageW, height: pageH, scale: COVER.captureScale, profileRoot: runDir, env, label, log });
218
+ const open = (label, useRung) => openCover({ dir, fonts, width: pageW, height: pageH, scale: COVER.captureScale, profileRoot: runDir, env, label, rung: useRung ?? rung, log });
215
219
  const a = await open('master');
216
220
  try {
217
221
  master = await capturePass({ page: a, pool, times, dir: dirs.frames, prefix: 'f', width: w, height: h });
218
222
  log(`cover: master ${times.length} frames (${((Date.now() - started) / 1000).toFixed(1)} s)`);
219
223
  await capturePass({ page: a, pool, times, dir: dirs.masks, prefix: 'm', mask: true, width: w, height: h });
224
+ rungUsed = a.rung;
225
+ rendererUsed = a.renderer;
226
+ if (a.notice) { warnings.push(a.notice); log(`warning: ${a.notice}`); }
220
227
  } finally { await a.close(); }
221
- const b = await open('replay');
228
+ const b = await open('replay', rungUsed);
222
229
  try { replay = await capturePass({ page: b, pool, times, dir: dirs.frames2, prefix: 'f', width: w, height: h }); } finally { await b.close(); }
223
- const c = await open('seek');
230
+ const c = await open('seek', rungUsed);
224
231
  try { sought = await capturePass({ page: c, pool, times: order.map((i) => times[i]), dir: dirs.seek, prefix: 's', width: w, height: h }); } finally { await c.close(); }
225
232
  } finally { await pool.close(); }
226
233
  const captureSec = (Date.now() - started) / 1000;
@@ -279,7 +286,7 @@ export async function renderCover({ out, round = 1, env = process.env, log = (li
279
286
  const summary = { pass: gate.pass, target: gate.target, medianD: gate.medianD, movingFrac: gate.movingFrac, longestStillSec: gate.longestStillSec, spike: gate.spike, seamLocal: gate.seamLocal, gates: gate.gates };
280
287
  writeJson(join(out, 'manifest.json'), {
281
288
  schemaVersion: 1, path: 'cover', mode: 'cover', round, recipe: treatment.recipe, treatmentSha256, stillsIndexSha256: indexed.sha256,
282
- page: treatment.page, size: treatment.out, scale: COVER.captureScale, fps: treatment.fps, frames: times.length, frameMs: ms, seed: treatment.seed,
289
+ page: treatment.page, size: treatment.out, scale: COVER.captureScale, fps: treatment.fps, frames: times.length, frameMs: ms, seed: treatment.seed, rung: rungUsed, renderer: rendererUsed ? `${rendererUsed} on the ${rungUsed} rung (cover path)` : null,
283
290
  encoder: encoded.encoder, encode: treatment.encode, file: encoded.file.split('/').at(-1), bytes: statSync(encoded.file).size, captureSec, warnings, gate: summary,
284
291
  });
285
292
  if (!flash.pass) {
@@ -326,7 +333,7 @@ export async function renderCoverWindow({ out, round = 1, window: [t0, t1], env
326
333
  mkdirSync(profileRoot, { recursive: true });
327
334
  const pool = new CoverPool();
328
335
  try {
329
- const page = await openCover({ dir: coverDir, fonts, width: treatment.page[0], height: treatment.page[1], scale: COVER.captureScale, profileRoot, env, label: 'window', log });
336
+ const page = await openCover({ dir: coverDir, fonts, width: treatment.page[0], height: treatment.page[1], scale: COVER.captureScale, profileRoot, env, label: 'window', rung: treatment.recipe === 'hero-3d' ? 'gpu' : 'software', log });
330
337
  try { await capturePass({ page, pool, times: [...new Set(picks.map((p) => p.index))].map((i) => [i, i / treatment.frames]), dir, prefix: 'f', width: w, height: h }); } finally { await page.close(); }
331
338
  } finally { await pool.close(); }
332
339
  const file = (i) => join(dir, `f${pad4(i)}.png`);
@@ -33,12 +33,89 @@ export function luminanceP995(rgba) {
33
33
  return 1;
34
34
  }
35
35
 
36
+ const luma8 = (rgba, i) => Math.round((0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2]));
37
+
38
+ /** Gamma-light luminance percentile (0..1) of an opaque RGBA frame. */
39
+ export function luminancePercentile(rgba, q) {
40
+ const bins = new Uint32Array(256);
41
+ const total = rgba.length / 4;
42
+ for (let i = 0; i < rgba.length; i += 4) bins[luma8(rgba, i)] += 1;
43
+ const target = total * q;
44
+ let acc = 0;
45
+ for (let i = 0; i < 256; i += 1) { acc += bins[i]; if (acc >= target) return i / 255; }
46
+ return 1;
47
+ }
48
+
49
+ // The frame cut into 4 x 3 regions for the rich-fill rule.
50
+ export const FILL_GRID = Object.freeze({ cols: 4, rows: 3 });
51
+
52
+ /**
53
+ * Share of 48 px cells that carry something: a cell is busy when its luma varies (sd > 6) or its
54
+ * mean sits more than 16 levels off the frame's ground, the most common luma smoothed over +-3.
55
+ * Sampled on a 3 px lattice; an opaque RGBA frame in, a 0..1 share out.
56
+ */
57
+ export function busyShare(rgba, width, height, { cell = 48, step = 3 } = {}) {
58
+ const hist = new Uint32Array(256);
59
+ for (let y = 0; y < height; y += step) for (let x = 0; x < width; x += step) hist[luma8(rgba, (y * width + x) * 4)] += 1;
60
+ let ground = 0;
61
+ let best = -1;
62
+ for (let i = 0; i < 256; i += 1) {
63
+ let s = 0;
64
+ for (let k = Math.max(0, i - 3); k <= Math.min(255, i + 3); k += 1) s += hist[k];
65
+ if (s > best) { best = s; ground = i; }
66
+ }
67
+ let busy = 0;
68
+ let cells = 0;
69
+ for (let cy = 0; cy + cell <= height; cy += cell) {
70
+ for (let cx = 0; cx + cell <= width; cx += cell) {
71
+ let s = 0;
72
+ let s2 = 0;
73
+ let n = 0;
74
+ let mean = 0;
75
+ for (let y = cy; y < cy + cell; y += step) for (let x = cx; x < cx + cell; x += step) {
76
+ const l = luma8(rgba, (y * width + x) * 4);
77
+ s += l; s2 += l * l; n += 1;
78
+ }
79
+ if (!n) continue;
80
+ mean = s / n;
81
+ const sd = Math.sqrt(Math.max(0, s2 / n - mean * mean));
82
+ if (sd > 6 || Math.abs(mean - ground) > 16) busy += 1;
83
+ cells += 1;
84
+ }
85
+ }
86
+ return cells ? busy / cells : 0;
87
+ }
88
+
89
+ /** The busy share of each region of the FILL_GRID, row by row. */
90
+ export function regionFill(rgba, width, height) {
91
+ const out = [];
92
+ const rw = Math.floor(width / FILL_GRID.cols);
93
+ const rh = Math.floor(height / FILL_GRID.rows);
94
+ for (let r = 0; r < FILL_GRID.rows; r += 1) {
95
+ for (let c = 0; c < FILL_GRID.cols; c += 1) {
96
+ const sub = new Uint8Array(rw * rh * 4);
97
+ for (let y = 0; y < rh; y += 1) {
98
+ const from = ((r * rh + y) * width + c * rw) * 4;
99
+ sub.set(rgba.subarray(from, from + rw * 4), y * rw * 4);
100
+ }
101
+ out.push(busyShare(sub, rw, rh));
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
36
107
  export function measureFrame(png, { flash = true } = {}) {
37
108
  const decoded = decodePng(png);
38
109
  const rgba = toRgba(decoded);
39
110
  const sha256 = createHash('sha256').update(rgba).digest('hex');
40
111
  const grid = flash ? cellGrid(rgba, decoded.width, decoded.height, 4) : null;
41
- return { width: decoded.width, height: decoded.height, rgba, sha256, grid, lumP995: flash ? luminanceP995(rgba) : null };
112
+ return {
113
+ width: decoded.width, height: decoded.height, rgba, sha256, grid,
114
+ lumP995: flash ? luminanceP995(rgba) : null,
115
+ lumP005: luminancePercentile(rgba, 0.005),
116
+ busy: busyShare(rgba, decoded.width, decoded.height),
117
+ regions: regionFill(rgba, decoded.width, decoded.height),
118
+ };
42
119
  }
43
120
 
44
121
  if (!isMainThread && parentPort) {