@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
@@ -372,3 +372,146 @@ export function audioGate({ mp4, videoDurationSec, mode, ffmpeg, ffprobe }) {
372
372
  const detail = `${mode} track ${stats.durationSec} s, peak ${stats.peakDbfs} dBFS, quiet opening ${stats.quietOpeningSec} s${problems.length ? `; ${problems.join('; ')}` : ''}`;
373
373
  return done(status, detail, exit20, stats);
374
374
  }
375
+
376
+ // ---- layered score primitives ------------------------------------------------------------
377
+
378
+ // A normalized single-cycle table of additive partials (TABLE + 1 samples, wrapped).
379
+ const TABLE = 4096;
380
+ export { TABLE as WAVETABLE_SIZE };
381
+ export function wavetable(harmonics) {
382
+ const table = new Float32Array(TABLE + 1);
383
+ let peak = 0;
384
+ for (let i = 0; i < TABLE; i += 1) {
385
+ let v = 0;
386
+ harmonics.forEach((a, k) => { if (a) v += a * Math.sin((2 * Math.PI * (k + 1) * i) / TABLE); });
387
+ table[i] = v;
388
+ peak = Math.max(peak, Math.abs(v));
389
+ }
390
+ for (let i = 0; i < TABLE; i += 1) table[i] /= peak || 1;
391
+ table[TABLE] = table[0];
392
+ return table;
393
+ }
394
+
395
+ export function samplePeakDbfs(channels) {
396
+ let peak = 0;
397
+ for (const ch of channels) for (let i = 0; i < ch.length; i += 1) {
398
+ const v = Math.abs(ch[i]);
399
+ if (v > peak) peak = v;
400
+ }
401
+ return dbfs(peak);
402
+ }
403
+
404
+ // Sounds an on-screen event can make: a tap, a hit, a whoosh and a chime.
405
+ export const EVENT_SOUNDS = Object.freeze(['tap', 'hit', 'whoosh', 'chime']);
406
+
407
+ const CUE_WORDS = Object.freeze([
408
+ ['hit', /\b(?:hits?|impacts?|cuts?|stabs?|accents?)\b|타격|히트|컷|강세/iu],
409
+ ['rise', /\b(?:rises?|rising|risers?|swells?|swelling|builds?|building|build-?ups?|lifts?|lifting)\b|상승|고조|빌드업/iu],
410
+ ['cadence', /\b(?:cadences?|resolves?|resolving|resolution|close|closing|ending|final chord)\b|종지|마무리|해결/iu],
411
+ ]);
412
+
413
+ // Accent kinds a beat's free-text `sound` field asks for; none when nothing matches.
414
+ export function cueKindsFor(soundText) {
415
+ const text = String(soundText ?? '');
416
+ return CUE_WORDS.filter(([, re]) => re.test(text)).map(([kind]) => kind);
417
+ }
418
+
419
+ const nearestTime = (times, t, within) => {
420
+ let best = null;
421
+ for (const c of times) if (Math.abs(c - t) <= within && (best === null || Math.abs(c - t) < Math.abs(best - t))) best = c;
422
+ return best;
423
+ };
424
+
425
+ const round4 = (x) => Math.round(x * 10000) / 10000;
426
+
427
+ // Accent cues measured against the beat boundary or cut they serve, plus one `event` cue per
428
+ // on-screen event at its own time (an event never snaps to a cut: it follows the picture).
429
+ export function planCues({ beats = [], durationSec, cutTimes = [], events = [] }) {
430
+ const cues = [];
431
+ const clampT = (t) => Math.min(durationSec, Math.max(0, t));
432
+ beats.forEach((beat, beatIndex) => {
433
+ const t0 = clampT(Number(beat.t0)), t1 = clampT(Number(beat.t1));
434
+ for (const kind of cueKindsFor(beat.sound)) {
435
+ if (kind === 'hit') {
436
+ const cut = nearestTime(cutTimes, t0, 0.5);
437
+ const target = cut ?? t0;
438
+ cues.push({ kind, t: round4(target), beatIndex, target: round4(target), delta: 0 });
439
+ } else if (kind === 'rise') {
440
+ const cut = nearestTime(cutTimes, t1, 0.5);
441
+ const end = cut ?? t1;
442
+ const start = Math.max(t0, end - 1.0);
443
+ cues.push({ kind, t: round4(end), start: round4(start), beatIndex, target: round4(end), delta: 0 });
444
+ } else {
445
+ const t = t0 + 0.6 * (t1 - t0);
446
+ cues.push({ kind, t: round4(t), beatIndex, target: round4(t), delta: 0, end: round4(beatIndex === beats.length - 1 ? durationSec : t1) });
447
+ }
448
+ }
449
+ });
450
+ for (const e of Array.isArray(events) ? events : []) {
451
+ if (!EVENT_SOUNDS.includes(e?.kind) || !(e.t >= 0 && e.t < durationSec)) continue;
452
+ const beatIndex = Math.max(0, beats.findIndex((b) => e.t >= Number(b.t0) && e.t < Number(b.t1)));
453
+ const cut = nearestTime(cutTimes, e.t, Infinity);
454
+ cues.push({ kind: 'event', sound: e.kind, t: round4(e.t), beatIndex, target: round4(e.t), delta: 0, on: String(e.on ?? ''), fromCut: cut === null ? null : round4(Math.abs(e.t - cut)) });
455
+ }
456
+ cues.sort((a, b) => a.t - b.t || a.beatIndex - b.beatIndex);
457
+ return cues;
458
+ }
459
+
460
+ // The master stage every built track ends in: loudness to -16 LUFS through a lookahead limiter
461
+ // under -2 dBFS, quantized as the WAV will be. The limiter turns the gain down smoothly before a
462
+ // peak (5 ms lookahead, both channels linked, 80 ms release) instead of bending the waveform:
463
+ // a waveshaper adds harmonics the AAC encode turns into overshoot.
464
+ export function masterTo(L, R) {
465
+ const sampleCount = L.length;
466
+ const ceiling = 10 ** (-2.1 / 20);
467
+ const ahead = Math.round(0.005 * SAMPLE_RATE);
468
+ const release = 1 - Math.exp(-1 / (0.08 * SAMPLE_RATE));
469
+ const q = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 32767) / 32767;
470
+ const render = (gain) => {
471
+ // need[s]: the gain that keeps sample s under the ceiling; held[s]: the lowest need in the
472
+ // next `ahead` samples (a monotonic deque); then the release lets it rise slowly; then a
473
+ // moving average over `ahead` samples reaches every held value before its peak.
474
+ const need = new Float32Array(sampleCount);
475
+ for (let s = 0; s < sampleCount; s += 1) {
476
+ const a = Math.max(Math.abs(L[s]), Math.abs(R[s])) * gain;
477
+ need[s] = a > ceiling ? ceiling / a : 1;
478
+ }
479
+ const held = new Float32Array(sampleCount);
480
+ const deque = new Int32Array(sampleCount + ahead + 1);
481
+ let head = 0, tail = 0, next = 0;
482
+ for (let s = 0; s < sampleCount; s += 1) {
483
+ for (; next < Math.min(sampleCount, s + ahead + 1); next += 1) {
484
+ while (tail > head && need[deque[tail - 1]] >= need[next]) tail -= 1;
485
+ deque[tail] = next; tail += 1;
486
+ }
487
+ while (deque[head] < s) head += 1;
488
+ held[s] = need[deque[head]];
489
+ }
490
+ let env = 1;
491
+ const smooth = new Float32Array(sampleCount);
492
+ for (let s = 0; s < sampleCount; s += 1) {
493
+ env = Math.min(held[s], env + (1 - env) * release);
494
+ smooth[s] = env;
495
+ }
496
+ const left = new Float32Array(sampleCount), right = new Float32Array(sampleCount);
497
+ // The averaging window starts full of unity gain; the hard ceiling below catches the first 5 ms.
498
+ let win = ahead;
499
+ for (let s = 0; s < sampleCount; s += 1) {
500
+ win += smooth[s] - (s >= ahead ? smooth[s - ahead] : 1);
501
+ const g = win / ahead;
502
+ left[s] = q(Math.max(-ceiling, Math.min(ceiling, L[s] * gain * g)));
503
+ right[s] = q(Math.max(-ceiling, Math.min(ceiling, R[s] * gain * g)));
504
+ }
505
+ return [left, right];
506
+ };
507
+ const raw = integratedLoudness([Float32Array.from(L), Float32Array.from(R)]);
508
+ let gain = Number.isFinite(raw) ? 10 ** ((TARGET_LUFS - raw) / 20) : 1;
509
+ let [left, right] = render(gain);
510
+ let lufs = integratedLoudness([left, right]);
511
+ for (let i = 0; i < 8 && Number.isFinite(lufs) && Math.abs(lufs - TARGET_LUFS) > 0.2; i += 1) {
512
+ gain *= 10 ** ((TARGET_LUFS - lufs) / 20);
513
+ [left, right] = render(gain);
514
+ lufs = integratedLoudness([left, right]);
515
+ }
516
+ return { left, right, gain, lufs };
517
+ }
@@ -4,6 +4,7 @@
4
4
  import { existsSync, realpathSync } from 'node:fs';
5
5
  import { isAbsolute, join, sep } from 'node:path';
6
6
  import { findTool } from './render.mjs';
7
+ import { writeScore } from './score.mjs';
7
8
  import { audioGate, prepareTrack, writeBed } from './sound.mjs';
8
9
 
9
10
  export function soundFile(out, treatment) {
@@ -14,6 +15,10 @@ export function soundFile(out, treatment) {
14
15
  export function prepareSound({ out, treatment, fps, frameCount, cutTimes = [], ffmpeg, tempo = null }) {
15
16
  const mode = treatment.sound.mode;
16
17
  if (mode === 'none') return null;
18
+ if (mode === 'score') {
19
+ const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
20
+ return { mode, path: score.file, score };
21
+ }
17
22
  if (mode === 'generated') {
18
23
  const bed = writeBed(out, treatment, { frameCount, fps, cutTimes, tempo });
19
24
  return { mode, path: bed.wavPath, bed };
@@ -30,8 +35,12 @@ export function prepareSound({ out, treatment, fps, frameCount, cutTimes = [], f
30
35
 
31
36
  export function muxGate({ mp4, durationSec, treatment, env = process.env, track }) {
32
37
  const mode = treatment.sound.mode;
33
- const gate = audioGate({ mp4, videoDurationSec: durationSec, mode, ffmpeg: findTool('ffmpeg', env), ffprobe: findTool('ffprobe', env) });
34
- const bed = track?.bed;
35
- const summary = { mode, label: mode === 'generated' ? 'generated sound bed' : mode === 'none' ? 'silent by request' : `${mode} track`, ...(bed ? { timbre: bed.timbre, key: bed.key, tempo: bed.tempo, lufs: bed.lufs, peakDbfs: bed.peakDbfs, method: bed.method, sha256: bed.sha256 } : {}), ...(track?.prepared ? { sourceSec: track.prepared.sourceSec, padded: track.prepared.padded, trimmed: track.prepared.trimmed } : {}) };
38
+ // A score is built here to the loudness target like the generated bed: a quiet opening is a
39
+ // failure, not a warning.
40
+ const built = mode === 'score' ? 'generated' : mode;
41
+ const gate = audioGate({ mp4, videoDurationSec: durationSec, mode: built, ffmpeg: findTool('ffmpeg', env), ffprobe: findTool('ffprobe', env) });
42
+ const bed = track?.bed, score = track?.score;
43
+ const label = mode === 'score' && score ? `score, ${score.genre} in ${score.key}, ${score.tempo} BPM` : mode === 'generated' ? 'generated sound bed' : mode === 'none' ? 'silent by request' : `${mode} track`;
44
+ const summary = { mode, label, ...(bed ? { timbre: bed.timbre, key: bed.key, tempo: bed.tempo, lufs: bed.lufs, peakDbfs: bed.peakDbfs, method: bed.method, sha256: bed.sha256 } : {}), ...(score ? { genre: score.genre, key: score.key, tempo: score.tempo, lufs: score.lufs, peakDbfs: score.peakDbfs, method: score.loudnessMethod, sidechainDb: score.sidechainDb, sha256: score.sha256 } : {}), ...(track?.prepared ? { sourceSec: track.prepared.sourceSec, padded: track.prepared.padded, trimmed: track.prepared.trimmed } : {}) };
36
45
  return { results: gate.results, exit20: gate.exit20, stats: { ...gate.stats, ...summary }, summary };
37
46
  }
@@ -13,6 +13,48 @@ const pass = (id, detail = '') => ({ id, status: 'PASS', detail });
13
13
  const fail = (id, detail) => ({ id, status: 'FAIL', detail });
14
14
  const warn = (id, detail) => ({ id, status: 'WARN', detail });
15
15
 
16
+ // An empty frame of any colour: almost every pixel within 0.02 of one luminance (0.5-99.5 %).
17
+ // A cream or white frame with nothing on it is as empty as a black one; MO-D-03 sees only black.
18
+ const FLAT = Object.freeze({ spread: 0.02, holdSec: 1, edgeSec: 1 });
19
+ // A near-empty frame: fewer than 2.5 % of its 48 px cells carry anything. A run longer than 0.5 s
20
+ // fails; the opening may build from nothing for 0.4 s more, the close may fade for 0.6 s more.
21
+ const DEAD = Object.freeze({ busy: 0.025, holdSec: 0.5, openSec: 0.4, closeSec: 0.6 });
22
+
23
+ const worstRun = (flags, { limit, allowance }) => {
24
+ let worst = null;
25
+ let start = -1;
26
+ for (let f = 0; f <= flags.length; f++) {
27
+ if (f < flags.length && flags[f]) {
28
+ if (start < 0) start = f;
29
+ continue;
30
+ }
31
+ if (start >= 0) {
32
+ const length = f - start;
33
+ if (length > limit + allowance(start === 0, f === flags.length) && (!worst || length > worst.length)) worst = { length, from: start };
34
+ start = -1;
35
+ }
36
+ }
37
+ return worst;
38
+ };
39
+
40
+ export function checkFlatFrames(frames, fps) {
41
+ if (!frames.length || frames.some((f) => typeof f.lumP005 !== 'number' || typeof f.lumP995 !== 'number')) return { id: 'FLAT-FRAME', status: 'N/A', detail: 'per-frame spread not recorded' };
42
+ const flat = frames.map((f) => f.lumP995 - f.lumP005 < FLAT.spread);
43
+ const worst = worstRun(flat, { limit: FLAT.holdSec * fps, allowance: (atStart, atEnd) => (atStart || atEnd ? FLAT.edgeSec * fps : 0) });
44
+ return worst
45
+ ? fail('FLAT-FRAME', `${(worst.length / fps).toFixed(2)} s of empty frames from ${(worst.from / fps).toFixed(2)} s (nothing but one flat colour; limit ${FLAT.holdSec} s)`)
46
+ : pass('FLAT-FRAME', `no run of empty frames longer than ${FLAT.holdSec} s`);
47
+ }
48
+
49
+ export function checkDeadAir(frames, fps) {
50
+ if (!frames.length || frames.some((f) => typeof f.busy !== 'number')) return { id: 'DEAD-AIR', status: 'N/A', detail: 'per-frame busy share not recorded' };
51
+ const dead = frames.map((f) => f.busy < DEAD.busy);
52
+ const worst = worstRun(dead, { limit: DEAD.holdSec * fps, allowance: (atStart, atEnd) => (atStart ? DEAD.openSec * fps : 0) + (atEnd ? DEAD.closeSec * fps : 0) });
53
+ return worst
54
+ ? fail('DEAD-AIR', `${(worst.length / fps).toFixed(2)} s of near-empty frames from ${(worst.from / fps).toFixed(2)} s (under ${DEAD.busy * 100} % of the frame carries anything; limit ${DEAD.holdSec} s)`)
55
+ : pass('DEAD-AIR', `no near-empty run longer than ${DEAD.holdSec} s`);
56
+ }
57
+
16
58
  function exportInfo(path) {
17
59
  return existsSync(path) ? { present: true, bytes: statSync(path).size, path, name: path.split('/').pop() } : { present: false };
18
60
  }
@@ -66,6 +108,8 @@ export function evaluateStage({ manifest, frames, previewRecords, determinism, p
66
108
  const still = exportInfo(join(exportsDir, 'reduced-motion.png'));
67
109
  results.push(still.present ? pass('MO-C-14', `present Y: the final beat's midpoint frame ${stillFrame}`) : fail('MO-C-14', 'present N: reduced-motion still missing'));
68
110
  results.push(checkDarkRuns({ fps: manifest.fps, bpm: manifest.sound?.tempo ?? 100, timeline: [] }, frames));
111
+ results.push(checkFlatFrames(frames, manifest.fps));
112
+ results.push(checkDeadAir(frames, manifest.fps));
69
113
  results.push(...(sound?.results ?? [fail('SOUND', 'sound gate not run')]));
70
114
  results.push(...(qa?.results ?? []));
71
115
  results.push(manifest.round >= 1 && manifest.round <= 3 ? pass('MO-C-15/16', `round ${manifest.round} of 3`) : fail('MO-C-15/16', `round ${manifest.round} outside 1..3`));
@@ -8,11 +8,45 @@ import { join } from 'node:path';
8
8
  import { SKILL_ROOT } from './fonts.mjs';
9
9
  import { measureContrast } from './gate.mjs';
10
10
  import { measureFrame } from './frame-pool.mjs';
11
+ import { LAYOUTS, ZONE_TOLERANCE, readStyle, scaleStep, styleFamilies, styleTextColors } from './styles.mjs';
12
+
13
+ const readStyleSafe = (id) => {
14
+ try {
15
+ return readStyle(id);
16
+ } catch {
17
+ return null;
18
+ }
19
+ };
11
20
  import { openStage } from './stage-session.mjs';
12
21
  import { readingFloor } from './text.mjs';
13
22
  import { normalize } from './treatment.mjs';
14
23
 
15
24
  export const INTERNAL_TERMS = Object.freeze(['path', 'preset', 'gate', 'beat', 'treatment']);
25
+
26
+ // Thresholds for the text and fill rules: visible opacity, ink pixels, overlap share of the smaller
27
+ // box, RGB distance for "the same colour", empty regions per beat midpoint.
28
+ export const TEXT_QA = Object.freeze({ visibleOpacity: 0.6, minInkPx: 12, overlapShare: 0.08, colourDistance: 12, emptyRegion: 0.15, emptyRegionsMax: 2 });
29
+
30
+ /** [r, g, b] of a CSS colour string (rgb(), rgba() or #hex), or null. */
31
+ export function parseCssColour(value) {
32
+ const text = String(value ?? '').trim();
33
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/u.exec(text);
34
+ if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
35
+ const hex = /^#([0-9a-f]{6})$/iu.exec(text);
36
+ if (hex) return [0, 2, 4].map((i) => Number.parseInt(hex[1].slice(i, i + 2), 16));
37
+ return null;
38
+ }
39
+
40
+ /** Classify a run against the treatment's copy: "copy" | "other" (decor is never copy here). */
41
+ export function runClass(run, copyNorms) {
42
+ const n = run.norm;
43
+ if (!n) return 'other';
44
+ return copyNorms.some((line) => line.includes(n) || n.includes(line)) ? 'copy' : 'other';
45
+ }
46
+
47
+ const boxArea = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
48
+ const boxOverlap = (a, b) => boxArea([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
49
+ const unionOf = (rects) => (rects.length ? [Math.min(...rects.map((r) => r[0])), Math.min(...rects.map((r) => r[1])), Math.max(...rects.map((r) => r[2])), Math.max(...rects.map((r) => r[3]))] : null);
16
50
  const QA_SCRIPT = join(SKILL_ROOT, 'scripts', 'stage', 'qa.js');
17
51
  const LUT = Float64Array.from({ length: 256 }, (_, v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
18
52
  const pass = (id, detail = '') => ({ id, status: 'PASS', detail });
@@ -107,11 +141,17 @@ function rgb3(rgba) {
107
141
  return out;
108
142
  }
109
143
 
110
- export async function runStageQa({ executable, out, stageDir, width, height, fps, seed, fonts, treatment, totalFrames, log = () => {}, stepOrThrow, captureChecked, stop = { aborted: false } }) {
144
+ export async function runStageQa({ executable, out, stageDir, width, height, fps, seed, fonts, treatment, totalFrames, log = () => {}, stepOrThrow, captureChecked, rung = 'software', stop = { aborted: false } }) {
111
145
  const plan = qaPlan(treatment.beats, fps, totalFrames);
112
146
  const allowedFonts = new Set();
113
147
  for (const route of fonts.routes.values()) for (const name of fontNames(new Uint8Array(readFileSync(route.path)))) allowedFonts.add(name);
114
- const session = await openStage({ executable, outDir: out, stageDir, width, height, fps, seed, fonts, label: 'q', log });
148
+ let style = null;
149
+ if (treatment.style) {
150
+ const { readStyle, styleCss, styleScript } = await import('./styles.mjs');
151
+ const declared = readStyle(treatment.style);
152
+ style = { css: styleCss(declared, treatment.format), js: styleScript(declared, treatment.format, treatment) };
153
+ }
154
+ const session = await openStage({ executable, outDir: out, stageDir, width, height, fps, seed, fonts, style, label: 'q', rung, log });
115
155
  const grid = [];
116
156
  const samples = [];
117
157
  let canvasTextCalls = 0;
@@ -157,7 +197,7 @@ export async function runStageQa({ executable, out, stageDir, width, height, fps
157
197
  } catch { /* the node left the document between reads */ }
158
198
  }
159
199
  }
160
- samples.push({ frame: f, beat: sample.beat, kind: sample.kind, runs: measured, texts, moved: stats.moved, nonTextStd: stats.nonTextStd, fonts: fontsUsed });
200
+ samples.push({ frame: f, beat: sample.beat, kind: sample.kind, runs: measured, texts, moved: stats.moved, nonTextStd: stats.nonTextStd, fonts: fontsUsed, regions: a.regions ?? null });
161
201
  }
162
202
  } finally { await session.close().catch(() => {}); }
163
203
  const fileNames = ['index.html', 'treatment.json', 'stage-kit.js', 'fonts.css', 'gate-report.txt'];
@@ -166,6 +206,92 @@ export async function runStageQa({ executable, out, stageDir, width, height, fps
166
206
 
167
207
  function wordHit(text, term) { return new RegExp(`(?<![\\p{L}\\p{N}])${term}(?![\\p{L}\\p{N}])`, 'iu').test(text); }
168
208
 
209
+ /**
210
+ * The text rules on the collected samples: text over text (any visible runs), and — when the
211
+ * treatment declares a house style — the style's colours, faces and type scale for copy, plus the
212
+ * shot's type zone for settled copy runs on beats that name a layout. Pure, so tests drive it.
213
+ */
214
+ export function judgeTextRules({ treatment, width, height, samples, settled, copyNorms }) {
215
+ const format = width >= height ? '16:9' : '9:16';
216
+ const style = treatment.style ? readStyleSafe(treatment.style) : null;
217
+ const colours = style ? styleTextColors(style).map(parseCssColour) : [];
218
+ const families = style ? styleFamilies(style) : new Set();
219
+ const overlaps = [];
220
+ const offColour = [];
221
+ const offFace = [];
222
+ const offScale = [];
223
+ const offScaleOther = [];
224
+ const offZone = [];
225
+ const tolX = width * ZONE_TOLERANCE;
226
+ const tolY = height * ZONE_TOLERANCE;
227
+ for (const s of samples) {
228
+ const visible = s.runs
229
+ .map((run) => ({ run, box: unionOf(run.rects) }))
230
+ .filter(({ run, box }) => box && !run.decor && run.opacity >= TEXT_QA.visibleOpacity && run.ink >= TEXT_QA.minInkPx);
231
+ for (let i = 0; i < visible.length; i++) {
232
+ for (let j = i + 1; j < visible.length; j++) {
233
+ const a = visible[i];
234
+ const b = visible[j];
235
+ const shared = boxOverlap(a.box, b.box);
236
+ if (shared > TEXT_QA.overlapShare * Math.min(boxArea(a.box), boxArea(b.box))) overlaps.push(`"${a.run.text.slice(0, 24)}" × "${b.run.text.slice(0, 24)}"@${s.frame}`);
237
+ }
238
+ }
239
+ const beat = treatment.beats[s.beat];
240
+ const layout = beat?.layout ? LAYOUTS[format][beat.layout] : null;
241
+ for (const { run, box } of visible) {
242
+ if (!settled(s, run)) continue;
243
+ const copy = !run.decor && runClass(run, copyNorms) === 'copy';
244
+ const where = `"${run.text.slice(0, 32)}"@${s.frame}`;
245
+ if (copy && style) {
246
+ const rgb = parseCssColour(run.color);
247
+ if (!run.gradient && rgb && !colours.some((c) => Math.hypot(c[0] - rgb[0], c[1] - rgb[1], c[2] - rgb[2]) <= TEXT_QA.colourDistance)) offColour.push(`${where} ${run.color}`);
248
+ if (run.family && ![...families].some((f) => run.family === f || run.family.startsWith(`${f} `))) offFace.push(`${where} ${run.family}`);
249
+ }
250
+ if (style) {
251
+ const fontPx = run.fontSizePx * (run.scale ?? 1);
252
+ if (!scaleStep(style, format, fontPx)) (copy ? offScale : offScaleOther).push(`${where} ${Math.round(fontPx)}px`);
253
+ }
254
+ if (copy && layout) {
255
+ const z = layout.type;
256
+ if (!z) offZone.push(`${where} in a full shot (no copy zone)`);
257
+ else {
258
+ const [zx, zy, zw, zh] = [z[0] * width, z[1] * height, z[2] * width, z[3] * height];
259
+ if (box[0] < zx - tolX || box[1] < zy - tolY || box[2] > zx + zw + tolX || box[3] > zy + zh + tolY) offZone.push(`${where} outside the ${beat.layout} type zone`);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ const rules = [];
265
+ rules.push(overlaps.length ? fail('TEXT-OVERLAP', `text over text: ${overlaps.slice(0, 4).join(', ')}`) : pass('TEXT-OVERLAP', 'no two visible text runs overlap'));
266
+ if (style) {
267
+ const steps = Object.entries(style.scale[format]).map(([k, v]) => `${k} ${v}`).join(', ');
268
+ const styleFails = [
269
+ offColour.length ? `colour off the ${style.id} text roles (${style.textRoles.join(', ')}): ${offColour.slice(0, 3).join(', ')}` : null,
270
+ offFace.length ? `face outside the style (${[...families].join(', ')}): ${offFace.slice(0, 3).join(', ')}` : null,
271
+ offScale.length ? `size off the type scale (${steps} px, ±4 %): ${offScale.slice(0, 3).join(', ')}` : null,
272
+ ].filter(Boolean);
273
+ rules.push(styleFails.length ? fail('TEXT-ON-STYLE', styleFails.join('; '))
274
+ : offScaleOther.length ? warn('TEXT-ON-STYLE', `copy is on the ${style.id} style; other text off the scale: ${offScaleOther.slice(0, 3).join(', ')}`)
275
+ : pass('TEXT-ON-STYLE', `copy uses the ${style.id} colours, faces and type scale`));
276
+ }
277
+ const zoned = samples.some((s) => treatment.beats[s.beat]?.layout);
278
+ if (zoned) rules.push(offZone.length ? fail('TEXT-ZONE', offZone.slice(0, 4).join(', ')) : pass('TEXT-ZONE', "every settled copy run sits in its shot's type zone"));
279
+ return rules;
280
+ }
281
+
282
+ // A rich film fills its frame the way a dense reference does: at each beat midpoint at most two of
283
+ // the 12 regions may sit empty (busy share under 0.15). The end card may breathe, so one beat may miss.
284
+ export function richFill({ treatment, samples }) {
285
+ if (treatment.register === 'minimal') return { id: 'RICH-FILL', status: 'N/A', detail: 'minimal register: negative space is the point' };
286
+ const mids = samples.filter((s) => s.kind === 'mid' && Array.isArray(s.regions));
287
+ if (!mids.length) return { id: 'RICH-FILL', status: 'N/A', detail: 'region fill not measured' };
288
+ const thin = mids
289
+ .map((s) => ({ beat: s.beat, empty: s.regions.filter((v) => v < TEXT_QA.emptyRegion).length }))
290
+ .filter((x) => x.empty > TEXT_QA.emptyRegionsMax);
291
+ if (thin.length <= 1) return { id: 'RICH-FILL', status: 'PASS', detail: `${mids.length - thin.length} of ${mids.length} beat midpoints fill the frame (at most ${TEXT_QA.emptyRegionsMax} of 12 regions empty)` };
292
+ return { id: 'RICH-FILL', status: 'FAIL', detail: `${thin.length} of ${mids.length} beat midpoints leave the frame empty: ${thin.map((x) => `beat ${x.beat + 1} has ${x.empty} of 12 regions empty`).join(', ')}` };
293
+ }
294
+
169
295
  export function evaluateQa({ grid, samples, plan, treatment, width, height, fps, canvasTextCalls = 0, allowedFonts = new Set(), fileNames = [] }) {
170
296
  const copy = treatment.copy.lines.map(normalize).filter(Boolean);
171
297
  const isCopyText = (norm) => copy.some((line) => norm.includes(line) || line.includes(norm));
@@ -262,6 +388,9 @@ export function evaluateQa({ grid, samples, plan, treatment, width, height, fps,
262
388
  results.push(decorCopy || decorShare ? fail('QA-DECOR', decorCopy ?? decorShare) : pass('QA-DECOR', 'decor text carries no copy and stays under 25 % of the text area'));
263
389
  results.push(meta ? warn('QA-META', `a meta label may be on screen: ${meta}; the look must answer it`) : pass('QA-META', 'no request text, idea, file name or internal term on screen'));
264
390
  results.push(fontIssue ? (fontIssue.copy ? fail('QA-FONTS', `${fontIssue.text}; set copy in a product face`) : warn('QA-FONTS', fontIssue.text)) : pass('QA-FONTS', 'only product faces at beat midpoints'));
391
+ results.push(...judgeTextRules({ treatment, width, height, samples, settled, copyNorms: copy, families: null }));
392
+ const rich = richFill({ treatment, samples });
393
+ results.push(rich);
265
394
  const registered = grid.some((g) => g.texts.length);
266
395
  results.push(canvasTextCalls > 0 && !registered ? warn('QA-CANVAS', 'canvas text not measured: the page draws text on a canvas without LitStage.text registrations; the look must answer it') : pass('QA-CANVAS', registered ? 'canvas text registered' : 'no canvas text'));
267
396
  const drawn = presence.filter(Boolean).length;
@@ -1,7 +1,7 @@
1
1
  // One stage Chrome: the page is served from the synthetic origin http://lit.stage/ through CDP
2
2
  // Fetch interception (no listening socket), the host script virtualizes time before any page
3
3
  // script, and the renderer drives frames with step() + capture() (brief sections 6c-6e).
4
- import { randomBytes } from 'node:crypto';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
5
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
6
6
  import { extname, join, sep } from 'node:path';
7
7
  import { STAGE } from './constants.mjs';
@@ -18,6 +18,37 @@ export const EPOCH_MS = Date.UTC(2026, 0, 1);
18
18
  export const KIT_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'stage-kit.js');
19
19
  const HOST_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'host.js');
20
20
 
21
+ // The pinned three.js build and its two addons (scripts/lib/vendor/three, MIT), served at /lit/three/
22
+ // for 3D films and 3D heroes; never a CDN. Any other path under /lit/three/ is a missing file.
23
+ // The hashes below are the bytes of upstream three@0.170.0
24
+ // (registry.npmjs.org/three/-/three-0.170.0.tgz: build/three.module.js and the two examples/jsm
25
+ // files), checked byte for byte: a swapped vendored file and a regenerated notice cannot pass
26
+ // together, because the pin lives in code and is rechecked every time a file is served.
27
+ export const THREE_DIR = join(SKILL_ROOT, 'scripts', 'lib', 'vendor', 'three');
28
+ export const THREE_HASHES = Object.freeze({
29
+ 'three.module.js': 'ce1fa418de16a19495a9f72495580e3015d7745c296d3ce0485897f902ddedfb',
30
+ 'addons/environments/RoomEnvironment.js': '85869bdd22f7cf0b22e68dcefe6cb710480af0f5c901a76f7a7764fd07fc8014',
31
+ 'addons/geometries/RoundedBoxGeometry.js': 'd20aeaf7077c459a8d952d215738d7b44fba028440bb862d84b622a34f0c80bb',
32
+ });
33
+
34
+ /** Resolve a /lit/three/... path against the vendored build: { file, type }, { missing } or { refused }. */
35
+ export function resolveThree(pathname) {
36
+ let decoded;
37
+ try { decoded = decodeURIComponent(pathname); } catch { return { refused: `undecodable path ${pathname}` }; }
38
+ const rel = decoded.slice('/lit/three/'.length);
39
+ if (rel.split('/').includes('..') || rel.includes('\\')) return { refused: `path traversal ${pathname}` };
40
+ if (!rel.endsWith('.js')) return { missing: rel };
41
+ const file = join(THREE_DIR, rel);
42
+ if (!existsSync(file)) return { missing: rel };
43
+ try {
44
+ const hash = createHash('sha256').update(readFileSync(file)).digest('hex');
45
+ if (THREE_HASHES[rel] !== hash) return { refused: `changed vendored file ${rel}` };
46
+ } catch {
47
+ return { refused: `unreadable vendored file ${rel}` };
48
+ }
49
+ return { file, type: CONTENT_TYPES['.js'] };
50
+ }
51
+
21
52
  export function hostScript({ seed, fps }) {
22
53
  return readFileSync(HOST_PATH, 'utf8').replace('const CFG = __LIT_CFG__;', `const CFG = ${JSON.stringify({ seed: seed >>> 0, fps, epochMs: EPOCH_MS })};`);
23
54
  }
@@ -41,9 +72,9 @@ export function resolveStagePath(stageDir, pathname) {
41
72
  return { file: real, type };
42
73
  }
43
74
 
44
- export async function openStage({ executable, outDir, stageDir, width, height, fps, seed, fonts, label = 'm', log = () => {} }) {
75
+ export async function openStage({ executable, outDir, stageDir, width, height, fps, seed, fonts, style = null, label = 'm', rung = 'software', log = () => {} }) {
45
76
  const profileDir = join(outDir, '.run', `s${label}-${randomBytes(3).toString('hex')}`);
46
- const browser = await launchStage({ executable, profileDir, width, height });
77
+ const browser = await launchStage({ executable, profileDir, width, height, rung });
47
78
  const network = [];
48
79
  const refused = [];
49
80
  const served = new Set();
@@ -64,7 +95,23 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
64
95
  return;
65
96
  }
66
97
  if (url.pathname === '/lit/stage-kit.js') { served.add('/lit/stage-kit.js'); await respond(requestId, 200, kit, CONTENT_TYPES['.js']); return; }
98
+ if (url.pathname.startsWith('/lit/three/')) {
99
+ const resolved = resolveThree(url.pathname);
100
+ if (resolved.refused) { refused.push(resolved.refused); await respond(requestId, 403, 'refused'); return; }
101
+ if (resolved.missing) { await respond(requestId, 404, 'not a vendored three.js file'); return; }
102
+ served.add(url.pathname);
103
+ await respond(requestId, 200, readFileSync(resolved.file), resolved.type);
104
+ return;
105
+ }
67
106
  if (url.pathname === '/lit/fonts.css') { served.add('/lit/fonts.css'); await respond(requestId, 200, fonts.css, CONTENT_TYPES['.css']); return; }
107
+ if (url.pathname === '/lit/style.css') {
108
+ if (style) { served.add('/lit/style.css'); await respond(requestId, 200, style.css, CONTENT_TYPES['.css']); } else await respond(requestId, 404, 'no style bound');
109
+ return;
110
+ }
111
+ if (url.pathname === '/lit/style.js') {
112
+ if (style) { served.add('/lit/style.js'); await respond(requestId, 200, style.js, CONTENT_TYPES['.js']); } else await respond(requestId, 404, 'no style bound');
113
+ return;
114
+ }
68
115
  if (url.pathname.startsWith('/lit/fonts/')) {
69
116
  const route = fonts.routes.get(url.pathname);
70
117
  if (route) { served.add(url.pathname); await respond(requestId, 200, readFileSync(route.path), route.type); } else await respond(requestId, 404, 'no such face');
@@ -95,6 +142,7 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
95
142
 
96
143
  return {
97
144
  network, refused, served, args: browser.args, send, evaluate,
145
+ rung: browser.rung, renderer: browser.renderer, notice: browser.notice,
98
146
  async load() {
99
147
  const loaded = new Promise((resolve) => browser.on((message) => { if (message.method === 'Page.loadEventFired' && message.sessionId === browser.sessionId) resolve(); }));
100
148
  await send('Page.navigate', { url: `${STAGE.origin}/index.html` });