@litfamily/litopencode 1.0.31 → 1.0.33

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 (282) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/TRADEMARK.md +12 -0
  5. package/dist/activation-primary-prompts.js +5 -1
  6. package/dist/activation-probe.js +1 -0
  7. package/dist/activation-prompt-utils.d.ts +1 -1
  8. package/dist/activation-reentry.d.ts +20 -0
  9. package/dist/activation-reentry.js +110 -1
  10. package/dist/activation-routing.d.ts +2 -1
  11. package/dist/activation-routing.js +100 -11
  12. package/dist/activation-workflow-prompts.d.ts +4 -0
  13. package/dist/activation-workflow-prompts.js +30 -0
  14. package/dist/activation.d.ts +1 -1
  15. package/dist/activation.js +44 -5
  16. package/dist/adhd-next.d.ts +51 -0
  17. package/dist/adhd-next.js +150 -0
  18. package/dist/agents/defaults.js +1 -1
  19. package/dist/cli/managed-skill-assets.d.ts +1 -1
  20. package/dist/cli/managed-skill-assets.js +7 -1
  21. package/dist/cli/native-skill-integrity.js +1 -1
  22. package/dist/cli.js +7 -1
  23. package/dist/commands.d.ts +28 -1
  24. package/dist/commands.js +58 -5
  25. package/dist/durable-plan.d.ts +5 -1
  26. package/dist/durable-plan.js +14 -2
  27. package/dist/features.d.ts +80 -1
  28. package/dist/features.js +105 -1
  29. package/dist/hooks.js +3 -0
  30. package/dist/ledger.js +12 -1
  31. package/dist/plan-scope-roots.d.ts +23 -0
  32. package/dist/plan-scope-roots.js +116 -0
  33. package/dist/restart-notice.d.ts +7 -0
  34. package/dist/restart-notice.js +40 -0
  35. package/dist/secret-shapes.js +5 -5
  36. package/dist/server.js +4 -0
  37. package/dist/session-liveness.d.ts +20 -0
  38. package/dist/session-liveness.js +99 -0
  39. package/dist/skills.d.ts +22 -1
  40. package/dist/skills.js +36 -0
  41. package/dist/start-work-typed.d.ts +34 -0
  42. package/dist/start-work-typed.js +167 -0
  43. package/dist/state.d.ts +3 -0
  44. package/dist/state.js +3 -0
  45. package/dist/tools.js +22 -2
  46. package/dist/tui/mission-band.d.ts +22 -1
  47. package/dist/tui/mission-band.js +75 -16
  48. package/dist/tui/mission-looks.d.ts +38 -0
  49. package/dist/tui/mission-looks.js +73 -0
  50. package/dist/tui/mission-model.d.ts +2 -0
  51. package/dist/tui/mission-model.js +3 -1
  52. package/dist/tui/mission-rainbow.d.ts +33 -0
  53. package/dist/tui/mission-rainbow.js +90 -0
  54. package/dist/tui/mission-source.d.ts +49 -0
  55. package/dist/tui/mission-source.js +217 -2
  56. package/dist/tui/mission-text.d.ts +2 -0
  57. package/dist/tui/mission-text.js +9 -1
  58. package/dist/tui/mission-threads.d.ts +58 -0
  59. package/dist/tui/mission-threads.js +128 -0
  60. package/dist/tui.js +112 -7
  61. package/dist/uiux-visual-catalog.js +8 -1
  62. package/dist/v2/events.js +3 -0
  63. package/dist/v2/session-hooks.js +38 -5
  64. package/dist/workflow-stack.d.ts +53 -0
  65. package/dist/workflow-stack.js +196 -0
  66. package/docs/assets/readme/badge-version.svg +1 -1
  67. package/docs/assets/skills/lit-schematic.webp +0 -0
  68. package/docs/reference-Ko-KR.md +48 -3
  69. package/docs/reference.md +54 -3
  70. package/package.json +1 -1
  71. package/skills/frontend-ui-ux/SKILL.md +3 -2
  72. package/skills/frontend-ui-ux/kit/components.css +414 -0
  73. package/skills/frontend-ui-ux/kit/fonts.json +22 -0
  74. package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  75. package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  76. package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  77. package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  78. package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  79. package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  80. package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  81. package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  82. package/skills/frontend-ui-ux/kit/ui.js +226 -0
  83. package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
  84. package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  85. package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
  86. package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
  87. package/skills/frontend-ui-ux/references/house-styles.md +236 -0
  88. package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
  89. package/skills/frontend-ui-ux/references/production.md +3 -1
  90. package/skills/frontend-ui-ux/references/slop-register.md +4 -4
  91. package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  92. package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  93. package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
  94. package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
  95. package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
  96. package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
  97. package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
  98. package/skills/lit-adhd/SKILL.md +209 -0
  99. package/skills/lit-adhd/references/brief-format.md +46 -0
  100. package/skills/lit-adhd/references/card-examples.md +103 -0
  101. package/skills/lit-adhd/references/status-taxonomy.md +34 -0
  102. package/skills/lit-adhd/scripts/rewind.mjs +933 -0
  103. package/skills/lit-diagram-drawer/SKILL.md +4 -3
  104. package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  105. package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  106. package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  107. package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  108. package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  109. package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
  110. package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
  111. package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
  112. package/skills/lit-docx/SKILL.md +2 -1
  113. package/skills/lit-handoff/SKILL.md +2 -0
  114. package/skills/lit-hijack/SKILL.md +422 -0
  115. package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
  116. package/skills/lit-hijack/references/clean-room-build.md +111 -0
  117. package/skills/lit-hijack/references/distil-and-licence.md +100 -0
  118. package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
  119. package/skills/lit-hijack/references/parity-and-report.md +110 -0
  120. package/skills/lit-hijack/scripts/hijack.mjs +993 -0
  121. package/skills/lit-plan/SKILL.md +8 -0
  122. package/skills/lit-pptx/SKILL.md +27 -11
  123. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  124. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  125. package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  126. package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
  127. package/skills/lit-pptx/references/density-and-fill.md +9 -3
  128. package/skills/lit-pptx/references/direction-step.md +21 -6
  129. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
  130. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
  131. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
  132. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
  133. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
  134. package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  135. package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  136. package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  137. package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  138. package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  139. package/skills/lit-pptx/references/layout-families.md +65 -47
  140. package/skills/lit-pptx/references/title-treatments.md +83 -134
  141. package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
  142. package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  143. package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
  144. package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  145. package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
  146. package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
  147. package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  148. package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  149. package/skills/lit-pptx/references/tonalities/night.md +1 -1
  150. package/skills/lit-pptx/references/tonalities/paper.md +2 -2
  151. package/skills/lit-pptx/references/tonalities/riso.md +137 -0
  152. package/skills/lit-pptx/references/tonalities/signal.md +3 -3
  153. package/skills/lit-pptx/references/tonalities/studio.md +7 -6
  154. package/skills/lit-pptx/run.mjs +2 -1
  155. package/skills/lit-pptx/scripts/compile-deck.js +1 -1
  156. package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
  157. package/skills/lit-pptx/scripts/craft_extras.py +56 -3
  158. package/skills/lit-pptx/scripts/deck_output.py +75 -90
  159. package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  160. package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
  161. package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  162. package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  163. package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
  164. package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  165. package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  166. package/skills/lit-pptx/scripts/qa_deck.py +10 -11
  167. package/skills/lit-pptx/scripts/render_pages.py +118 -0
  168. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  169. package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  170. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  171. package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  172. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  173. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  174. package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  175. package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  176. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  177. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  178. package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  179. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  180. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  181. package/skills/lit-recap/SKILL.md +4 -0
  182. package/skills/lit-schematic/SKILL.md +118 -0
  183. package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  184. package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  185. package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  186. package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  187. package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  188. package/skills/lit-schematic/references/isometric-craft.md +101 -0
  189. package/skills/lit-schematic/references/journal-figures.md +72 -0
  190. package/skills/lit-schematic/references/kit-api.md +134 -0
  191. package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  192. package/skills/lit-schematic/references/pid-drafting.md +103 -0
  193. package/skills/lit-schematic/references/pid-symbols.md +163 -0
  194. package/skills/lit-schematic/references/verification.md +100 -0
  195. package/skills/lit-schematic/scripts/doctor.mjs +57 -0
  196. package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  197. package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
  198. package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  199. package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  200. package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  201. package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  202. package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  203. package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  204. package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  205. package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  206. package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  207. package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  208. package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  209. package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  210. package/skills/lit-schematic/scripts/motion.mjs +129 -0
  211. package/skills/lit-schematic/scripts/qa.mjs +92 -0
  212. package/skills/lit-schematic/scripts/render.mjs +233 -0
  213. package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  214. package/skills/lit-scientific-visualization/SKILL.md +27 -4
  215. package/skills/lit-typographic-motion/SKILL.md +19 -17
  216. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
  217. package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
  218. package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
  219. package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
  220. package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
  221. package/skills/lit-typographic-motion/engine/board.mjs +138 -0
  222. package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
  223. package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
  224. package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
  225. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
  226. package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
  227. package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
  228. package/skills/lit-typographic-motion/engine/house.mjs +42 -0
  229. package/skills/lit-typographic-motion/engine/look.mjs +65 -8
  230. package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
  231. package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
  232. package/skills/lit-typographic-motion/engine/score.mjs +594 -0
  233. package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
  234. package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
  235. package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
  236. package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
  237. package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
  238. package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
  239. package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
  240. package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
  241. package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
  242. package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
  243. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
  244. package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
  245. package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
  246. package/skills/lit-typographic-motion/engine/text.mjs +1 -1
  247. package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
  248. package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  249. package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  250. package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  251. package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  252. package/skills/lit-typographic-motion/gate.mjs +18 -2
  253. package/skills/lit-typographic-motion/probe.mjs +13 -1
  254. package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
  255. package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
  256. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
  257. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
  258. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
  259. package/skills/lit-typographic-motion/references/cover.md +56 -13
  260. package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
  261. package/skills/lit-typographic-motion/references/depth.md +112 -0
  262. package/skills/lit-typographic-motion/references/runtime.md +21 -6
  263. package/skills/lit-typographic-motion/references/sound.md +76 -0
  264. package/skills/lit-typographic-motion/references/stage.md +50 -7
  265. package/skills/lit-typographic-motion/references/studio.md +162 -0
  266. package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
  267. package/skills/lit-typographic-motion/references/treatment.md +108 -9
  268. package/skills/lit-typographic-motion/render.mjs +60 -3
  269. package/skills/lit-typographic-motion/styles/paper.json +185 -0
  270. package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
  271. package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
  272. package/skills/lit-typographic-motion/styles/tidal.json +187 -0
  273. package/skills/litresearch/SKILL.md +2 -0
  274. package/skills/managed-skill-manifest.json +517 -88
  275. package/skills/readme-studio/SKILL.md +1 -1
  276. package/skills/review-work/SKILL.md +2 -0
  277. package/skills/start-work/SKILL.md +8 -0
  278. package/skills/workflow-loop/SKILL.md +4 -0
  279. package/tools/check-pack-payload.mjs +5 -2
  280. package/tools/payload-substance-parity.json +4 -3
  281. package/tools/qa-cover-recipes.mjs +4 -3
  282. package/tools/version-manifests.json +2 -2
@@ -7,16 +7,47 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { boxContrast } from "../contrast.mjs";
9
9
  import { linearLut } from "../flash.mjs";
10
+ import { regionFill } from "../fill.mjs";
10
11
  import { decodePng } from "../png.mjs";
11
12
  import { readingFloor } from "../text.mjs";
12
- import { normalize, withoutQuotes } from "../treatment.mjs";
13
+ import { LAYOUTS, ZONE_TOLERANCE, readStyle, scaleStep, styleFamilies, styleTextColors } from "../styles.mjs";
14
+ import { assetUsed } from "../assets.mjs";
15
+ import { normalize, registerOf, withoutQuotes } from "../treatment.mjs";
13
16
 
14
17
  const here = path.dirname(fileURLToPath(import.meta.url));
15
18
  export const qaPageSource = () => readFileSync(path.join(here, "qa-page.js"), "utf8");
16
- export const qaLimits = Object.freeze({ inkDelta: 2, minInkPixels: 12, visibleOpacity: 0.6, judgedOpacity: 0.95, settledPx: 2, decorShare: 0.25, presenceStd: 8 / 255, largeShare: 0.03, safeInset: 0.05, floorTolerance: 0.1 });
19
+ export const qaLimits = Object.freeze({
20
+ inkDelta: 2, minInkPixels: 12, visibleOpacity: 0.6, judgedOpacity: 0.95, settledPx: 2, decorShare: 0.25, presenceStd: 8 / 255, largeShare: 0.03, safeInset: 0.05, floorTolerance: 0.1,
21
+ // The house-style rules: two visible runs overlap past this share of the smaller box; copy colour may
22
+ // sit this far (RGB distance) from a style text role; shot changes are sampled this far on each side.
23
+ overlapShare: 0.08, emptyRegion: 0.15, emptyRegionsMax: 2, colourDistance: 12, cutWindowSec: 0.4, cutStepSec: 1 / 15, settledAt: Object.freeze([0.35, 0.8])
24
+ });
25
+ export const TEXT_QA = qaLimits;
17
26
  const internalTerms = /(?<![\p{L}\p{N}])(?:path|preset|gate|beat|treatment)(?![\p{L}\p{N}])/iu;
18
27
  const fileName = /(?<![\p{L}\p{N}])[\w-]+\.(?:html?|json|mjs|js|css|png|jpe?g|webp|svg|wav|mp4|md)(?![\p{L}\p{N}])/iu;
19
28
 
29
+ // The QA sample frames: each beat midpoint plus two settled frames, and a sample every 1/15 s within
30
+ // 0.4 s of each cut (`cut: true`), where an outgoing and an incoming line can cross. The cut rows feed
31
+ // only the text-over-text check; a line mid-transition is not judged for style or zone.
32
+ export function qaSamples({ beats, fps, frameCount }) {
33
+ const clamp = (f) => Math.max(0, Math.min(frameCount - 1, Math.round(f)));
34
+ const rows = new Map();
35
+ beats.forEach((beat, i) => {
36
+ for (const q of qaLimits.settledAt) rows.set(clamp((beat.t0 + q * (beat.t1 - beat.t0)) * fps), { frame: clamp((beat.t0 + q * (beat.t1 - beat.t0)) * fps), beat: i, mid: false });
37
+ });
38
+ beats.forEach((beat, i) => {
39
+ const frame = clamp(((beat.t0 + beat.t1) / 2) * fps);
40
+ rows.set(frame, { frame, beat: i, mid: true });
41
+ });
42
+ const reach = Math.round(qaLimits.cutWindowSec * fps), step = Math.max(1, Math.round(qaLimits.cutStepSec * fps));
43
+ beats.forEach((beat, i) => {
44
+ if (i === 0) return;
45
+ const at = Math.round(beat.t0 * fps);
46
+ for (let f = clamp(at - reach); f <= clamp(at + reach); f += step) if (!rows.has(f)) rows.set(f, { frame: f, beat: f < at ? i - 1 : i, mid: false, cut: true });
47
+ });
48
+ return [...rows.values()].sort((a, b) => a.frame - b.frame);
49
+ }
50
+
20
51
  export function titleSafeBox(width, height) {
21
52
  return [width * qaLimits.safeInset, height * qaLimits.safeInset, width * (1 - qaLimits.safeInset), height * (1 - qaLimits.safeInset)];
22
53
  }
@@ -39,10 +70,10 @@ const keyOf = (run, width, height) => {
39
70
  // Drives the QA browser; returns the raw samples and the 10 fps track for analyseQa().
40
71
  export async function captureQa(stage, context, frameCount) {
41
72
  const { fps, treatment } = context;
42
- const clamp = (f) => Math.max(0, Math.min(frameCount - 1, Math.round(f)));
43
- const mids = new Set(treatment.beats.map((b) => clamp(((b.t0 + b.t1) / 2) * fps)));
44
- const settled = treatment.beats.flatMap((b) => [0.35, 0.8].map((q) => clamp((b.t0 + q * (b.t1 - b.t0)) * fps)));
45
- const qaFrames = new Set([...mids, ...settled]);
73
+ const plan = qaSamples({ beats: treatment.beats, fps, frameCount });
74
+ const byFrame = new Map(plan.map((row) => [row.frame, row]));
75
+ const mids = new Set(plan.filter((row) => row.mid).map((row) => row.frame));
76
+ const qaFrames = new Set(plan.map((row) => row.frame));
46
77
  const trackStep = Math.max(1, Math.round(fps / 10));
47
78
  const page = stage.page, cdp = stage.cdp;
48
79
  await cdp.send("DOM.enable");
@@ -85,11 +116,99 @@ export async function captureQa(stage, context, frameCount) {
85
116
  fonts = marks.map((list) => [...new Set(list.flatMap((mark) => byMark.get(mark) ?? []))]);
86
117
  await page.evaluate(() => window.__litQa.unmarkFonts());
87
118
  }
88
- samples.push({ frame, mid: mids.has(frame), runs, a, b, fonts });
119
+ const row = byFrame.get(frame);
120
+ samples.push({ frame, beat: row.beat, mid: row.mid, cut: row.cut === true, runs, a, b, fonts });
89
121
  }
90
122
  return { track, samples, trackStep, allowed: [...allowed], canvasTexts: [...canvasTexts] };
91
123
  }
92
124
 
125
+ const inkIn = (diff, width, height, rects) => {
126
+ let n = 0;
127
+ for (const [x0, y0, x1, y1] of rects) {
128
+ for (let y = Math.max(0, Math.floor(y0)); y < Math.min(height, Math.ceil(y1)); y++) for (let x = Math.max(0, Math.floor(x0)); x < Math.min(width, Math.ceil(x1)); x++) if (diff[y * width + x]) n += 1;
129
+ }
130
+ return n;
131
+ };
132
+
133
+ // [r, g, b] of a CSS colour string (rgb(), rgba() or #hex), or null.
134
+ export function parseCssColour(value) {
135
+ const text = String(value ?? "").trim();
136
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/u.exec(text);
137
+ if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
138
+ const hex = /^#([0-9a-f]{6})$/iu.exec(text);
139
+ return hex ? [0, 2, 4].map((i) => Number.parseInt(hex[1].slice(i, i + 2), 16)) : null;
140
+ }
141
+
142
+ const area = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
143
+ const overlapArea = (a, b) => area([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
144
+
145
+ // The house-style rules on the collected samples, as gate rows. Text over text: any two visible,
146
+ // non-decor runs sharing more than 8 % of the smaller box, at the QA samples and across the cuts. For
147
+ // copy that is settled: the style's colours, faces and type scale, and the shot's type zone.
148
+ // Each sample is { frame, beat, cut, runs: [{ text, kind, copy, decor, settled, color, family, fontSizePx, gradient, box }] }.
149
+ export function judgeStyle({ treatment, width, height, samples }) {
150
+ const style = readStyle(treatment.style);
151
+ const format = width >= height ? "16:9" : "9:16";
152
+ const colours = styleTextColors(style).map(parseCssColour);
153
+ const families = styleFamilies(style);
154
+ const overlaps = [], offColour = [], offFace = [], offScale = [], offScaleOther = [], offZone = [];
155
+ const tolX = width * ZONE_TOLERANCE, tolY = height * ZONE_TOLERANCE;
156
+ for (const sample of samples) {
157
+ const visible = sample.runs.filter((run) => run.box && !run.decor);
158
+ for (let i = 0; i < visible.length; i++) for (let j = i + 1; j < visible.length; j++) {
159
+ const a = visible[i], b = visible[j];
160
+ if (overlapArea(a.box, b.box) > qaLimits.overlapShare * Math.min(area(a.box), area(b.box))) overlaps.push(`"${a.text.slice(0, 24)}" x "${b.text.slice(0, 24)}"@${sample.frame}${sample.cut ? " (cut)" : ""}`);
161
+ }
162
+ if (sample.cut) continue;
163
+ const beat = treatment.beats[sample.beat];
164
+ const layout = beat?.layout ? LAYOUTS[format][beat.layout] : null;
165
+ for (const run of sample.runs) {
166
+ if (run.kind === "canvas" || !run.settled || !run.box) continue;
167
+ const where = `"${run.text.slice(0, 32)}"@${sample.frame}`;
168
+ if (run.copy) {
169
+ const rgb = parseCssColour(run.color);
170
+ if (!run.gradient && rgb && !colours.some((c) => Math.hypot(c[0] - rgb[0], c[1] - rgb[1], c[2] - rgb[2]) <= qaLimits.colourDistance)) offColour.push(`${where} ${run.color}`);
171
+ if (run.family && !families.has(run.family)) offFace.push(`${where} ${run.family}`);
172
+ }
173
+ if (run.fontSizePx && !scaleStep(style, format, run.fontSizePx)) (run.copy ? offScale : offScaleOther).push(`${where} ${Math.round(run.fontSizePx)}px`);
174
+ if (run.copy && layout) {
175
+ const z = layout.type;
176
+ if (!z) offZone.push(`${where} in a full shot (no copy zone)`);
177
+ else {
178
+ const [zx, zy, zw, zh] = [z[0] * width, z[1] * height, z[2] * width, z[3] * height];
179
+ if (run.box[0] < zx - tolX || run.box[1] < zy - tolY || run.box[2] > zx + zw + tolX || run.box[3] > zy + zh + tolY) offZone.push(`${where} outside the ${beat.layout} type zone`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ const list = (items) => `${items.slice(0, 3).join(", ")}${items.length > 3 ? ` (+${items.length - 3} more)` : ""}`;
185
+ const steps = Object.entries(style.scale[format]).map(([k, v]) => `${k} ${v}`).join(", ");
186
+ const styleFails = [
187
+ offColour.length ? `colour off the ${style.id} text roles (${style.textRoles.join(", ")}): ${list(offColour)}` : null,
188
+ offFace.length ? `face outside the style (${[...families].join(", ")}): ${list(offFace)}` : null,
189
+ offScale.length ? `size off the type scale (${steps} px, +-4 %): ${list(offScale)}` : null
190
+ ].filter(Boolean);
191
+ return {
192
+ "TEXT-OVERLAP": overlaps.length ? { pass: false, detail: `text over text: ${list(overlaps)}` } : { pass: true, detail: `no two visible text runs overlap at the QA samples${samples.some((s) => s.cut) ? " or across the cuts" : ""}` },
193
+ "TEXT-ON-STYLE": styleFails.length ? { pass: false, detail: styleFails.join("; ") }
194
+ : offScaleOther.length ? { pass: false, warn: true, detail: `copy is on the ${style.id} style; other text off the scale: ${list(offScaleOther)}` }
195
+ : { pass: true, detail: `copy uses the ${style.id} colours, faces and type scale` },
196
+ "TEXT-ZONE": offZone.length ? { pass: false, detail: list(offZone) } : { pass: true, detail: "every settled copy run sits in its shot's type zone" }
197
+ };
198
+ }
199
+
200
+ // RICH-FILL: at each shot's midpoint the frame is cut into 4 x 3 regions, and a region under 0.15 busy
201
+ // share is empty. A rich film leaves at most 2 of the 12 empty in all but one shot (the end card may
202
+ // breathe). A minimal film is exempt: negative space is its point.
203
+ export function richFill({ treatment, mids }) {
204
+ if (registerOf(treatment) === "minimal") return { pass: true, na: true, detail: "minimal register: negative space is the point" };
205
+ const measured = mids.filter((sample) => Array.isArray(sample.regions));
206
+ if (!measured.length) return { pass: true, na: true, detail: "region fill not measured" };
207
+ const thin = measured.map((sample) => ({ beat: sample.beat, empty: sample.regions.filter((value) => value < qaLimits.emptyRegion).length })).filter((shot) => shot.empty > qaLimits.emptyRegionsMax);
208
+ if (thin.length <= 1) return { pass: true, detail: `${measured.length - thin.length} of ${measured.length} shot midpoints fill the frame (at most ${qaLimits.emptyRegionsMax} of 12 regions empty${thin.length ? `; shot ${thin[0].beat + 1} may breathe` : ""})` };
209
+ return { pass: false, detail: `${thin.length} of ${measured.length} shot midpoints leave the frame empty: ${thin.map((shot) => `shot ${shot.beat + 1} has ${shot.empty} of 12 regions empty`).join(", ")} (a rich film fills the frame: more drawing, more labels, a layer behind the subject)` };
210
+ }
211
+
93
212
  // Turns the captures into gate rows. Copy runs (their text holds a copy line) FAIL; decor runs WARN.
94
213
  export function analyseQa(raw, context) {
95
214
  const { width, height, fps, treatment } = context;
@@ -104,8 +223,11 @@ export function analyseQa(raw, context) {
104
223
  const trackByFrame = new Map(raw.track.map((entry) => [entry.frame, entry]));
105
224
  let presenceHits = 0, presenceMids = 0;
106
225
  const judged = [];
226
+ const styleSamples = [];
227
+ const fillSamples = [];
107
228
  for (const sample of raw.samples) {
108
229
  const A = rgbaOf(sample.a), B = rgbaOf(sample.b);
230
+ if (sample.mid) fillSamples.push({ beat: sample.beat, regions: regionFill(A, width, height) });
109
231
  const grow = (run) => Math.max(2, 0.2 * run.fontSize * run.scale);
110
232
  const textRects = sample.runs.flatMap((run) => run.rects.map(([x0, y0, x1, y1]) => [x0 - grow(run), y0 - grow(run), x1 + grow(run), y1 + grow(run)]));
111
233
  const inText = (x, y) => textRects.some(([x0, y0, x1, y1]) => x >= x0 && x < x1 && y >= y0 && y < y1);
@@ -117,6 +239,18 @@ export function analyseQa(raw, context) {
117
239
  if (!inText(p % width, Math.floor(p / width))) outside += 1;
118
240
  }
119
241
  }
242
+ if (sample.cut) {
243
+ // A shot change in flight: only the text-over-text check reads it (judgeStyle).
244
+ if (outside > 0) continue;
245
+ const runs = [];
246
+ for (const run of sample.runs) {
247
+ if (run.kind === "canvas" || run.decor || run.opacity < qaLimits.visibleOpacity || !onScreen(run.rects, width, height)) continue;
248
+ if (inkIn(diff, width, height, run.rects) < qaLimits.minInkPixels) continue;
249
+ runs.push({ text: run.text, kind: run.kind, copy: isCopy(run), decor: false, settled: false, box: union(run.rects) });
250
+ }
251
+ styleSamples.push({ frame: sample.frame, beat: sample.beat, cut: true, runs });
252
+ continue;
253
+ }
120
254
  if (sample.mid) {
121
255
  presenceMids += 1;
122
256
  let n = 0, sum = 0, sumSq = 0;
@@ -129,6 +263,7 @@ export function analyseQa(raw, context) {
129
263
  }
130
264
  if (outside > 0) { warns.moved.push(`frame ${sample.frame}: ${outside} pixels changed outside the text (state moved; sample discarded)`); continue; }
131
265
  let visibleArea = 0, decorArea = 0;
266
+ const styleRuns = [];
132
267
  sample.runs.forEach((run, index) => {
133
268
  if (run.kind === "canvas") { if (run.opacity >= qaLimits.visibleOpacity && onScreen(run.rects, width, height)) seenVisible.add(run.norm); return; }
134
269
  const mask = new Uint8Array(width * height);
@@ -162,6 +297,7 @@ export function analyseQa(raw, context) {
162
297
  const mine = union(run.rects), theirs = match ? union(match.rects) : null;
163
298
  return theirs && mine.every((v, k) => Math.abs(v - theirs[k]) < qaLimits.settledPx);
164
299
  });
300
+ styleRuns.push({ text: run.text, kind: run.kind, copy, decor: run.decor, settled, color: run.color, family: run.family, fontSizePx: run.fontSize * run.scale, gradient: run.gradient, box });
165
301
  if (!settled) return;
166
302
  const sizePx = run.fontSize * run.scale;
167
303
  const measured = boxContrast(A, mask, width, height, { bbox: box, capHeightPx: sizePx, fill: run.gradient ? "gradient" : "solid", fontSizePx: sizePx, weight: run.weight }, shortSide / 1080, sizePx >= qaLimits.largeShare * shortSide);
@@ -169,6 +305,7 @@ export function analyseQa(raw, context) {
169
305
  judged.push({ frame: sample.frame, text: run.text, ratio: Number(measured.ratio.toFixed(2)), floor: measured.floor, copy, decor: run.decor });
170
306
  if (measured.ratio < measured.floor) (copy && !run.decor ? fails.contrast : warns.contrast).push(`"${run.text}" at frame ${sample.frame}: ${measured.ratio.toFixed(2)}:1 < ${measured.floor}:1`);
171
307
  });
308
+ styleSamples.push({ frame: sample.frame, beat: sample.beat, cut: false, runs: styleRuns });
172
309
  if (visibleArea > 0 && decorArea / visibleArea > qaLimits.decorShare) fails.decor.push(`decor text covers ${Math.round((100 * decorArea) / visibleArea)} % of the visible text at frame ${sample.frame}`);
173
310
  }
174
311
  // The 1/10 s track: visible copy runs for the reading floor, and every run for the meta-label lint.
@@ -202,6 +339,10 @@ export function analyseQa(raw, context) {
202
339
  "TEXT-decor": row(fails.decor, "decor text carries no copy and stays under 25 % of the text"),
203
340
  "TEXT-fonts": row(fails.fonts, `copy draws only with ${raw.allowed.join(", ")}`)
204
341
  };
342
+ const used = context.stageDir ? assetUsed(treatment, context.stageDir) : null;
343
+ if (used) checks["ASSET-USED"] = used;
344
+ if (treatment.path === "stage") checks["RICH-FILL"] = richFill({ treatment, mids: fillSamples });
345
+ if (treatment.style) Object.assign(checks, judgeStyle({ treatment, width, height, samples: styleSamples }));
205
346
  for (const [id, value] of Object.entries({ "TEXT-decor-contrast": warnRow(warns.contrast, "decor contrast"), "TEXT-moved": warnRow(warns.moved, "samples discarded"), "TEXT-meta-labels": warnRow([...warns.meta], "request, idea, file name or internal term on screen"),
206
347
  "TEXT-canvas": warnRow(warns.canvas, "canvas text not measured"), "TEXT-decor-fonts": warnRow(warns.fonts, "decor fonts"), "TEXT-presence": warns.presence ? { pass: false, warn: true, detail: warns.presence } : null })) if (value) checks[id] = value;
207
348
  return { checks, missing, judged, samples: raw.samples.map((sample) => ({ frame: sample.frame, mid: sample.mid, runs: sample.runs.length })), trackFrames: raw.track.length, allowedFaces: raw.allowed };
@@ -23,6 +23,9 @@ import { SOUND_EXIT, muxTrack, resolveTrack, soundChecks } from "../sound.mjs";
23
23
  import { frameSize, keepFirstTreatment, loadTreatment } from "../treatment.mjs";
24
24
  import { inspectWarmState, pins, skillRoot } from "../../runtime.mjs";
25
25
  import { previewEncoderRung } from "../../probe.mjs";
26
+ import { addToStills, boardRules, writeBoard } from "../board.mjs";
27
+ import { readStyle, styleCss, styleScript } from "../styles.mjs";
28
+ import { requirePlate } from "../blender.mjs";
26
29
  import { openStage, stageFonts } from "./capture.mjs";
27
30
  import { analyseQa, captureQa, qaPageSource } from "./qa.mjs";
28
31
  import { StageError, scanStage, stageSha } from "./scan.mjs";
@@ -111,6 +114,7 @@ export function prepareStage(out) {
111
114
  const stageDir = path.join(out, "stage");
112
115
  if (!existsSync(path.join(stageDir, "index.html"))) throw new StageError(17, "STAGE_CONTRACT_ERROR", `no stage page at ${path.join(stageDir, "index.html")}; write it first (references/stage.md)`);
113
116
  const scan = scanStage(stageDir);
117
+ const plate = requirePlate(treatment, stageDir);
114
118
  const state = inspectWarmState();
115
119
  if (state.blocked) throw exit(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
116
120
  const chrome = findChrome();
@@ -118,19 +122,26 @@ export function prepareStage(out) {
118
122
  const { width, height } = frameSize(treatment);
119
123
  const fps = treatment.fps ?? 60;
120
124
  const seed = Number.isSafeInteger(treatment.seed) ? treatment.seed >>> 0 : fnv1a32(treatment.request);
121
- return { treatment, stageDir, scan, state, chromePath: chrome.path, width, height, fps, seed, fonts: stageFonts(skillRoot, state, pins) };
125
+ const style = readStyle(treatment.style);
126
+ const styleFiles = { "/lit/style.css": styleCss(style, treatment.format), "/lit/style.js": styleScript(style, treatment.format, treatment) };
127
+ return { treatment, stageDir, scan, state, chromePath: chrome.path, width, height, fps, seed, fonts: stageFonts(skillRoot, state, pins), style, styleFiles, rung: stageRung(treatment), plate, notices: [] };
122
128
  }
123
129
 
124
- // Opens a stage browser, loads the page and checks its contract against the treatment.
125
- async function openChecked(chromium, context, profile, extraInit) {
126
- const stage = await openStage(chromium, { chromePath: context.chromePath, profile, stageDir: context.stageDir, width: context.width, height: context.height, fps: context.fps, seed: context.seed, fonts: context.fonts, extraInit });
130
+ // A three.js world asks for the GPU rung; every other film is on the software rung, a Blender film too:
131
+ // its world is the plate, and the page only draws type over it.
132
+ export const stageRung = (treatment) => (treatment.depth === "3d" && treatment.render3d !== "blender" ? "gpu" : "swiftshader");
133
+
134
+ const fellBack = (why) => `the 3D route asked for the GPU rung and ${why}; rendered on the software rung (SwiftShader), slower, same frames`;
135
+
136
+ // Opens one browser on `rung`, loads the page and checks its contract against the treatment.
137
+ async function openOnRung(chromium, context, profile, extraInit, rung) {
138
+ const stage = await (context.opener ?? openStage)(chromium, { chromePath: context.chromePath, profile, stageDir: context.stageDir, width: context.width, height: context.height, fps: context.fps, seed: context.seed, fonts: context.fonts, extraInit, styleFiles: context.styleFiles, rung });
127
139
  try {
128
140
  const { definition, webgl } = await stage.load(context.scan.rasters.map((raster) => `/${raster.rel}`));
129
141
  if (!definition) throw new StageError(17, "STAGE_CONTRACT_ERROR", "the page never called LitStage.define({ width, height, fps, duration, render }) or set window.litStage");
130
142
  if (definition.width !== context.width || definition.height !== context.height) throw new StageError(17, "STAGE_CONTRACT_ERROR", `the page defines ${definition.width}x${definition.height}; the treatment's ${context.treatment.format} needs ${context.width}x${context.height}`);
131
143
  if ((definition.fps ?? 60) !== context.fps) throw new StageError(17, "STAGE_CONTRACT_ERROR", `the page defines ${definition.fps} fps; the treatment renders at ${context.fps}`);
132
144
  if (!(Number.isFinite(definition.duration) && definition.duration > 0)) throw new StageError(17, "STAGE_CONTRACT_ERROR", "the page's duration must be a positive number of seconds");
133
- if (webgl.failed) throw exit(EXIT.BLOCKED_NO_WEBGL2, "BLOCKED_NO_WEBGL2", "the page asked for a WebGL context and the software renderer gave none; fix: check SwiftShader availability");
134
145
  return { stage, definition, webgl };
135
146
  } catch (error) {
136
147
  await stage.close();
@@ -138,6 +149,34 @@ async function openChecked(chromium, context, profile, extraInit) {
138
149
  }
139
150
  }
140
151
 
152
+ // The GPU rung comes first when the context asks for it. When it cannot start, or gives the page no
153
+ // WebGL, the whole round (every later browser too) takes the software rung and says so in a notice.
154
+ // A page that breaks the stage contract is not retried: it would break the same way there.
155
+ export async function openChecked(chromium, context, profile, extraInit) {
156
+ if (context.rung === "gpu") {
157
+ let opened;
158
+ try { opened = await openOnRung(chromium, context, profile, extraInit, "gpu"); }
159
+ catch (error) {
160
+ if (error?.exitCode !== undefined && error.exitCode !== EXIT.BLOCKED_NO_CHROME) throw error;
161
+ const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? "no detail";
162
+ context.rung = "swiftshader";
163
+ context.notices.push(fellBack(`it did not start (${line.slice(0, 160)})`));
164
+ }
165
+ if (opened) {
166
+ if (!opened.webgl.failed) return opened;
167
+ await opened.stage.close();
168
+ context.rung = "swiftshader";
169
+ context.notices.push(fellBack("the page got no WebGL there"));
170
+ }
171
+ }
172
+ const opened = await openOnRung(chromium, context, profile, extraInit, "swiftshader");
173
+ if (opened.webgl.failed) {
174
+ await opened.stage.close();
175
+ throw exit(EXIT.BLOCKED_NO_WEBGL2, "BLOCKED_NO_WEBGL2", "the page asked for a WebGL context and the software renderer gave none; fix: check SwiftShader availability");
176
+ }
177
+ return opened;
178
+ }
179
+
141
180
  // Captures `frames` (sorted) in one browser by stepping every frame from 0. The stills round skips the
142
181
  // compositor wait and the capture on frames it does not keep. The determinism replay (`exact`) repeats
143
182
  // the master's own sequence instead, settling and capturing every frame and keeping only the samples:
@@ -205,7 +244,7 @@ async function captureMaster(context, stage, frameCount, keep, previewSources, r
205
244
  const result = await job.promise;
206
245
  const record = detector.push(result.cells);
207
246
  hashes.set(job.frame, result.hash);
208
- writeSync(logFd, JSON.stringify({ frame: job.frame, rgbaSha256: result.hash, p995L: result.p995L, transitions: { up: record.up, down: record.down, redUp: record.redUp, redDown: record.redDown }, fullStep: record.fullStep }) + "\n");
247
+ writeSync(logFd, JSON.stringify({ frame: job.frame, rgbaSha256: result.hash, p995L: result.p995L, p005L: result.p005L, busy: result.busy, transitions: { up: record.up, down: record.down, redUp: record.redUp, redDown: record.redDown }, fullStep: record.fullStep }) + "\n");
209
248
  await write(master.child, Buffer.from(result.rgba.buffer, result.rgba.byteOffset, result.rgba.byteLength));
210
249
  if (result.previewPng) writeFileSync(path.join(pvDir, `f${job.frame}.png`), result.previewPng);
211
250
  consumed += 1;
@@ -288,6 +327,25 @@ function previewSourceFrames(fps, frameCount) {
288
327
  return sources;
289
328
  }
290
329
 
330
+ // The board round: the layout check (the text QA replay) runs on the page before any film is encoded,
331
+ // its rows go to stills/check.json, and the storyboard is drawn with the verdict on it. `qa` is the
332
+ // finished QA result when the full render already ran it.
333
+ async function boardRound(out, context, chromium, runDir, stills, { frameCount, qa = null }) {
334
+ let result = qa;
335
+ if (!result) {
336
+ const opened = await openChecked(chromium, context, path.join(runDir, "sq"), qaPageSource());
337
+ try { result = analyseQa(await captureQa(opened.stage, context, frameCount), context); } finally { await opened.stage.close(); }
338
+ }
339
+ const rules = boardRules(result.checks);
340
+ writeFileSync(path.join(out, "stills", "check.json"), JSON.stringify({ rules }, null, 2) + "\n");
341
+ const beats = stills.files.filter((entry) => entry.kind === "beat");
342
+ const file = await writeBoard({ out, context, chromium, treatment: context.treatment, beatFiles: beats.map((entry) => path.join(out, entry.file)), rules });
343
+ const entry = { file: "stills/board.png", kind: "board", frames: beats.map((beat) => beat.frame) };
344
+ addToStills(out, entry);
345
+ stills.files = JSON.parse(readFileSync(path.join(out, "stills", "stills.json"), "utf8")).files;
346
+ return { qa: result, rules, file, failed: rules.filter((rule) => rule.status === "FAIL") };
347
+ }
348
+
291
349
  // Moves the previous round's exports aside so a new render never mixes with them.
292
350
  function clearExports(out, runDir) {
293
351
  const previous = path.join(runDir, "previous-round");
@@ -322,6 +380,8 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
322
380
  stills = writeStillsSet(out, { width: context.width, height: context.height, fps: context.fps, frames, plan, round, pathName: "stage", beats: context.treatment.beats });
323
381
  resolveTrack(out, context.treatment, { frameCount, fps: context.fps, cuts: stageCuts(context) });
324
382
  writeFileSync(path.join(runDir, "stills-timing.json"), JSON.stringify({ frames: plan.all.length, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) }) + "\n");
383
+ const board = stillsOnly ? await boardRound(out, context, chromium, runDir, stills, { frameCount }) : null;
384
+ if (board) return { stillsOnly: true, stills, frameCount, board };
325
385
  if (noFfmpeg) throw exit(EXIT.BLOCKED_NO_FFMPEG_FOR_VIDEO, "BLOCKED_NO_FFMPEG_FOR_VIDEO", "ffmpeg not found on PATH; the stills and contact sheet were written; fix: install ffmpeg, or keep working from `stage --stills-only`, which needs no ffmpeg");
326
386
  return { stillsOnly: true, stills, frameCount };
327
387
  }
@@ -361,6 +421,7 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
361
421
  if (qaResult.missing.length) throw new StageError(17, "STAGE_CONTRACT_ERROR", `copy line never found on screen: ${qaResult.missing.map((line) => `"${line}"`).join(", ")}`);
362
422
  const frames = new Map([...plan.all].map((frame) => [frame, decodeRgba(master.kept.get(frame))]));
363
423
  stills = writeStillsSet(out, { width: context.width, height: context.height, fps: context.fps, frames, plan, round, pathName: "stage", beats: context.treatment.beats });
424
+ await boardRound(out, context, chromium, runDir, stills, { frameCount, qa: qaResult });
364
425
  const poster = posterPng(decodeRgba(master.kept.get(posterFrame)), context.width, context.height);
365
426
  writeFileSync(path.join(runDir, "poster.png"), poster.png);
366
427
  writeFileSync(path.join(runDir, "reduced-motion.png"), encodePng(context.width, context.height, decodeRgba(master.kept.get(stillFrame)), 4, 9, true));
@@ -385,10 +446,11 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
385
446
  const manifest = {
386
447
  schemaVersion: 1, path: "stage", format: context.treatment.format, resolution: [context.width, context.height], fps: context.fps, frameCount,
387
448
  durationSec: frameCount / context.fps, targetDurationSec: context.treatment.durationSec, seed: context.seed, craftRound: round,
388
- chromeFlags: stage.flags, webgl, previewEncoder: preview?.encoder ?? null,
449
+ chromeFlags: stage.flags, rung: context.rung, renderer: webgl.renderer ?? null, ...(context.notices.length ? { warnings: [...context.notices] } : {}), webgl, previewEncoder: preview?.encoder ?? null,
389
450
  preview: preview ? { width: preview.width, height: preview.height, fps: preview.fps, bytes: preview.bytes } : null,
390
451
  posterFrame, posterSize: [poster.width, poster.height], stillFrame, determinismFrames: samples,
391
452
  timing: { ...master.timing, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) },
453
+ ...(context.plate ? { plate: { blender: context.plate.blender, engine: context.plate.engine, device: context.plate.device, samples: context.plate.samples, fps: context.plate.fps, frames: context.plate.frames.length } } : {}),
392
454
  sound, qa: { file: ".run/qa.json", samples: qaResult.samples.length, trackFrames: qaResult.trackFrames, allowedFaces: qaResult.allowedFaces },
393
455
  treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), stageSha256: stageSha(context.stageDir),
394
456
  stillsSha256: sha256(readFileSync(path.join(out, "stills", "stills.json"))), pageErrors: stage.errors.slice(0, 5), missingAssets: stage.missing.slice(0, 5),
@@ -3,7 +3,7 @@
3
3
  // elements and APIs, flipbooks and animated rasters, and a raster budget overrun (exit 17). The same
4
4
  // rules also run live in the page (clock.js); this scan names the file and line first.
5
5
  import { createHash } from "node:crypto";
6
- import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
6
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
7
  import path from "node:path";
8
8
 
9
9
  export const STAGE_CONTRACT = 17;
@@ -15,6 +15,22 @@ export const contentTypes = Object.freeze({
15
15
  ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
16
16
  ".gif": "image/gif", ".wav": "audio/wav", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".json": "application/json"
17
17
  });
18
+ // The Blender plate (stage/plate/, and the preview stills in stage/plate-preview/): frames the runner wrote
19
+ // and plate.json lists by name and size. They are not page rasters. Returns { ...plate.json, dir } when every
20
+ // listed frame is there as listed, else null.
21
+ export function readPlate(stageDir, sub = "plate") {
22
+ const file = path.join(stageDir, sub, "plate.json");
23
+ if (!existsSync(file)) return null;
24
+ let plate;
25
+ try { plate = JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
26
+ if (!Array.isArray(plate.frames)) return null;
27
+ const listed = plate.frames.every((f) => {
28
+ const full = path.join(stageDir, sub, String(f.name));
29
+ return path.basename(String(f.name)) === f.name && existsSync(full) && statSync(full).size === f.bytes;
30
+ });
31
+ return listed ? { ...plate, dir: sub } : null;
32
+ }
33
+
18
34
  export const rasterLimits = Object.freeze({ files: 24, bytes: 8 * 1024 * 1024, sameSizeFlipbook: 10 });
19
35
  // XML namespace identifiers are names, not requests.
20
36
  const namespaceUris = /https?:\/\/www\.w3\.org\/(?:2000\/svg|1999\/xlink|1999\/xhtml|XML\/1998\/namespace|2000\/xmlns\/?|1998\/Math\/MathML)/gu;
@@ -113,9 +129,12 @@ export function scanStage(stageDir) {
113
129
  if (!files.includes("index.html")) throw contract("stage/index.html is missing");
114
130
  const rasters = [];
115
131
  let rasterBytes = 0;
132
+ const plates = ["plate", "plate-preview"].map((sub) => readPlate(stageDir, sub)).filter(Boolean);
133
+ const plated = new Set(plates.flatMap((plate) => plate.frames.map((f) => `${plate.dir}/${f.name}`)));
116
134
  for (const rel of files) {
117
135
  const ext = path.extname(rel).toLowerCase();
118
136
  const full = path.join(stageDir, rel);
137
+ if (rel === "scene.py" || plated.has(rel)) continue;
119
138
  if (!Object.hasOwn(contentTypes, ext)) throw contract(`${rel}: ${ext || "extensionless"} files are not served on the stage`);
120
139
  if (rasterExtensions.has(ext)) {
121
140
  const bytes = readFileSync(full);
@@ -1,8 +1,9 @@
1
1
  // LitOpenCode stage kit, served at /lit/stage-kit.js. Motion primitives only: eases, a spring,
2
2
  // keyframes, timing helpers, a seeded random source, text splitting, SVG path drawing, a single-path
3
- // morph, mask and clip helpers, colour mixing and the text registration the QA replay reads. It ships
4
- // no scene, object, layout or copy; the page draws whatever its treatment calls for. Every function is
5
- // a pure function of its arguments and the time the renderer passes to render(t).
3
+ // morph, mask and clip helpers, colour mixing, the shot and zone readers for the house style, and the
4
+ // text registration the QA replay reads. It ships no scene, object or copy; the page draws whatever
5
+ // its treatment calls for. Every function is a pure function of its arguments and the time the
6
+ // renderer passes to render(t).
6
7
  (function () {
7
8
  "use strict";
8
9
 
@@ -289,6 +290,86 @@
289
290
  registered.push({ content: String(content), x, y, w, h, decor: Boolean(decor) });
290
291
  }
291
292
 
293
+ // ---- the house style and the shot timeline ----
294
+ // The renderer serves both as /lit/style.js (window.LitStyle, window.LitTimeline), written from the
295
+ // treatment, so the page and the gate read one timeline and one set of zones. Read lazily: the page
296
+ // may load style.js before or after this kit.
297
+ const styleData = () => {
298
+ if (!window.LitStyle) throw new Error("LitStage: load /lit/style.js before the style helpers");
299
+ return window.LitStyle;
300
+ };
301
+ const timelineData = () => {
302
+ if (!window.LitTimeline) throw new Error("LitStage: load /lit/style.js before the shot helpers");
303
+ return window.LitTimeline;
304
+ };
305
+ const tokenCache = new WeakMap();
306
+ // The style's motion tokens as functions: eases, durations, staggers and a spring per object class
307
+ // from its damping ratio and frequency.
308
+ function tokens() {
309
+ const style = styleData();
310
+ if (tokenCache.has(style)) return tokenCache.get(style);
311
+ const m = style.motion;
312
+ const springOf = ({ zeta, hz }) => {
313
+ const k = (2 * Math.PI * hz) ** 2;
314
+ return spring({ stiffness: k, damping: 2 * zeta * Math.sqrt(k), mass: 1 });
315
+ };
316
+ const value = Object.freeze({
317
+ ease: Object.freeze({ enter: bezier(...m.ease.enter), move: bezier(...m.ease.move), exit: bezier(...m.ease.exit) }),
318
+ dur: Object.freeze({ ...m.dur }), stagger: Object.freeze({ ...m.stagger }),
319
+ spring: Object.freeze(Object.fromEntries(Object.entries(m.spring).map(([k, v]) => [k, springOf(v)]))),
320
+ secondary: Object.freeze({ ...m.secondary }), ambient: Object.freeze({ ...m.ambient }), push: m.push
321
+ });
322
+ tokenCache.set(style, value);
323
+ return value;
324
+ }
325
+ // The shot at time t: index, beat, layout, local seconds and progress, plus an entrance and an exit
326
+ // on the style's eases (enter(delay, dur) rises 0 -> 1, exit(dur) rises 0 -> 1 at the shot's end).
327
+ function shot(t) {
328
+ const beats = timelineData().beats;
329
+ let i = beats.findIndex((b) => t >= b.t0 && t < b.t1);
330
+ if (i < 0) i = t < beats[0].t0 ? 0 : beats.length - 1;
331
+ const b = beats[i];
332
+ const length = b.t1 - b.t0;
333
+ const tk = tokens();
334
+ return {
335
+ index: i, beat: b, layout: b.layout, transition: b.transition, local: t - b.t0, length, p: clamp((t - b.t0) / length),
336
+ enter: (delay = 0, dur = tk.dur.hero) => at(t, b.t0 + delay, dur, tk.ease.enter),
337
+ exit: (dur = tk.dur.exit) => at(t, b.t1 - dur, dur, tk.ease.exit)
338
+ };
339
+ }
340
+ // A layout zone in pixels ({ x, y, w, h }) for this film's format, and placing an element in it.
341
+ function zone(layout, part = "type") {
342
+ const z = styleData().zones[layout];
343
+ if (!z) throw new Error(`LitStage.zone: unknown layout ${layout}`);
344
+ if (!z[part]) throw new Error(`LitStage.zone: the ${layout} layout has no ${part} zone`);
345
+ return { ...z[part] };
346
+ }
347
+ function place(el, layout, part = "type") {
348
+ const z = zone(layout, part);
349
+ Object.assign(el.style, { position: "absolute", left: `${z.x}px`, top: `${z.y}px`, width: `${z.w}px`, height: `${z.h}px`, boxSizing: "border-box" });
350
+ return z;
351
+ }
352
+
353
+ // The Blender plate (render3d: "blender"): a full-frame <img> behind the type showing the plate frame for t.
354
+ // The element is replaced when the frame changes, and the renderer waits for its decode before the capture.
355
+ // Returns update(t); call it from render(t).
356
+ function plate({ parent, fps = 30, count, dir = "plate" } = {}) {
357
+ if (!parent || !(count > 0)) throw new Error("LitStage.plate: needs { parent, count } (count: the frames in plate/plate.json)");
358
+ let shown = -1, el = null;
359
+ return (t) => {
360
+ const i = Math.min(count - 1, Math.max(0, Math.floor(t * fps + 1e-6)));
361
+ if (i === shown) return i;
362
+ const next = document.createElement("img");
363
+ next.src = `${dir}/${String(i + 1).padStart(6, "0")}.png`;
364
+ next.alt = "";
365
+ Object.assign(next.style, { position: "absolute", inset: "0", width: "100%", height: "100%", objectFit: "cover" });
366
+ if (el) el.replaceWith(next); else parent.prepend(next);
367
+ el = next;
368
+ shown = i;
369
+ return i;
370
+ };
371
+ }
372
+
292
373
  // ---- the stage contract ----
293
374
  let definition = null;
294
375
  function define(spec) {
@@ -301,7 +382,7 @@
301
382
  const seeded = typeof window.__litStage?.seed === "number" ? window.__litStage.seed : 1;
302
383
  window.LitStage = {
303
384
  define, get definition() { return definition ?? window.litStage ?? null; },
304
- clamp, lerp, mapRange, ease, bezier, spring, kf, at, stagger, seq,
385
+ clamp, lerp, mapRange, ease, bezier, spring, kf, at, stagger, seq, shot, zone, place, plate, tokens,
305
386
  rand: Object.assign((seed) => rand(seed === undefined ? seeded : seed), { seed: seeded }),
306
387
  splitText, drawPath, morph, clip, mask, mix, text: Object.assign((arg, opts) => (arg instanceof Element ? text(arg, opts) : registerText(arg)), { registered }),
307
388
  get registeredText() { return registered; },
@@ -18,7 +18,7 @@ export function stillsPlan({ beats, cuts, frameCount, fps }) {
18
18
  return { beatFrames, strips, sheet, all };
19
19
  }
20
20
 
21
- function tile(images, cols, tileW, tileH, gap) {
21
+ export function tile(images, cols, tileW, tileH, gap) {
22
22
  const rows = Math.ceil(images.length / cols);
23
23
  const width = cols * tileW + (cols + 1) * gap, height = rows * tileH + (rows + 1) * gap;
24
24
  const out = Buffer.alloc(width * height * 4);