@litfamily/lithermes 1.0.29 → 1.0.30

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 (165) hide show
  1. package/README.md +12 -9
  2. package/README_Ko-KR.md +12 -9
  3. package/assets/lithermes-plugin/README.md +15 -2
  4. package/assets/lithermes-plugin/__init__.py +49 -10
  5. package/assets/lithermes-plugin/adhd_stack.py +228 -0
  6. package/assets/lithermes-plugin/core.py +30 -4
  7. package/assets/lithermes-plugin/core_commands.py +99 -0
  8. package/assets/lithermes-plugin/core_contexts.py +54 -4
  9. package/assets/lithermes-plugin/core_contract.py +2 -2
  10. package/assets/lithermes-plugin/core_routing.py +126 -0
  11. package/assets/lithermes-plugin/diagnostics.py +3 -2
  12. package/assets/lithermes-plugin/lit_mark.py +75 -0
  13. package/assets/lithermes-plugin/payload-version.json +470 -58
  14. package/assets/lithermes-plugin/plugin.yaml +1 -1
  15. package/assets/lithermes-plugin/scientific_visualization.py +23 -0
  16. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
  17. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  18. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  25. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  26. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  27. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  28. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
  29. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
  30. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
  31. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  32. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
  33. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
  34. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
  35. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
  36. package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
  37. package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
  38. package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
  39. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
  40. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
  41. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
  42. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
  43. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  44. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
  45. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
  46. package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
  47. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  48. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  49. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  50. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  51. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  52. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
  53. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
  54. package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
  55. package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
  56. package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  57. package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
  58. package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  59. package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  60. package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
  61. package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
  62. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
  63. package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
  64. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  65. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  66. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  67. package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
  68. package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
  69. package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
  70. package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
  71. package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
  72. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
  73. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
  74. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
  75. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
  76. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
  77. package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
  78. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
  79. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
  80. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
  81. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
  82. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
  83. package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
  84. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
  85. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
  86. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
  87. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
  88. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  89. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  90. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  91. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  92. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
  93. package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
  94. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  95. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  96. package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  97. package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  98. package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  99. package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
  100. package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
  101. package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
  102. package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  103. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
  104. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
  105. package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
  106. package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
  107. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  108. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  109. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  110. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  111. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  112. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  113. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  114. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  115. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  116. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  117. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  118. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  119. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  120. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  121. package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
  122. package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
  123. package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
  124. package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  125. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
  126. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
  127. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
  128. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
  129. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
  130. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  131. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
  132. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
  133. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
  134. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
  135. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
  136. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
  137. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
  138. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
  139. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
  140. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
  141. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
  142. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
  143. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
  144. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  145. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  146. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  147. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  148. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
  149. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  150. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
  151. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
  152. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
  153. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
  154. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  155. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  156. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  157. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  158. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
  159. package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
  160. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  161. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  162. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  163. package/package.json +1 -1
  164. package/readme-assets/badge-version.svg +1 -1
  165. package/src/lib/skillPayload.js +1 -1
@@ -270,6 +270,153 @@ export function buildBed({ out, treatment, fps, totalFrames, cuts }) {
270
270
  return { file, ...cueDoc };
271
271
  }
272
272
 
273
+ // ---------------------------------------------------------------------------
274
+ // Layered score DSP. The score engine (score.mjs) is built from these: cue
275
+ // planning, wavetable synthesis, the mastering stage and the WAV writer.
276
+
277
+ export const SOUND = Object.freeze({
278
+ sampleRate: 48000,
279
+ channels: 2,
280
+ bitsPerSample: 16,
281
+ targetLufs: -16,
282
+ lufsTolerance: 2,
283
+ bedPeakDbfs: -2,
284
+ gatePeakDbfs: -0.5,
285
+ silenceDbfs: -50,
286
+ silenceWindowSec: 0.05,
287
+ silenceMaxSec: 1.5,
288
+ silenceHeadSec: 3,
289
+ durationToleranceSec: 0.1,
290
+ fadeSec: 0.05,
291
+ });
292
+
293
+ // Sounds an on-screen event can make (treatment `sound.events`): a tap (a
294
+ // small click with a pitched body), a hit (the cut hit), a whoosh (air ending
295
+ // on the event) and a chime (two notes of the key: something completes).
296
+ export const EVENT_SOUNDS = Object.freeze(['tap', 'hit', 'whoosh', 'chime']);
297
+
298
+ const CUE_WORDS = [
299
+ ['hit', /\b(?:hits?|impacts?|cuts?|stabs?|accents?)\b|타격|히트|컷|강세/iu],
300
+ ['rise', /\b(?:rises?|rising|risers?|swells?|swelling|builds?|building|build-?ups?|lifts?|lifting)\b|상승|고조|빌드업/iu],
301
+ ['cadence', /\b(?:cadences?|resolves?|resolving|resolution|close|closing|ending|final chord)\b|종지|마무리|해결/iu],
302
+ ];
303
+
304
+ // Cue kinds a beat's free-text `sound` field asks for; none when nothing matches.
305
+ export function cueKindsFor(soundText) {
306
+ const text = String(soundText ?? '');
307
+ return CUE_WORDS.filter(([, re]) => re.test(text)).map(([kind]) => kind);
308
+ }
309
+
310
+ const nearest = (times, t, within) => {
311
+ let best = null;
312
+ for (const c of times) if (Math.abs(c - t) <= within && (best === null || Math.abs(c - t) < Math.abs(best - t))) best = c;
313
+ return best;
314
+ };
315
+ const round4 = (x) => Math.round(x * 10000) / 10000;
316
+
317
+ // Accent cues, each measured against the beat boundary or cut it serves, plus
318
+ // one `event` cue per on-screen event at its own time (it never snaps to a
319
+ // cut: it follows the picture).
320
+ export function planCues({ beats = [], durationSec, cutTimes = [], events = [] }) {
321
+ const cues = [];
322
+ const clampT = (t) => Math.min(durationSec, Math.max(0, t));
323
+ beats.forEach((beat, beatIndex) => {
324
+ const t0 = clampT(Number(beat.t0));
325
+ const t1 = clampT(Number(beat.t1));
326
+ for (const kind of cueKindsFor(beat.sound)) {
327
+ if (kind === 'hit') {
328
+ const cut = nearest(cutTimes, t0, 0.5);
329
+ const target = cut ?? t0;
330
+ cues.push({ kind, t: round4(target), beatIndex, target: round4(target), delta: 0 });
331
+ } else if (kind === 'rise') {
332
+ const cut = nearest(cutTimes, t1, 0.5);
333
+ const end = cut ?? t1;
334
+ const start = Math.max(t0, end - 1.0);
335
+ cues.push({ kind, t: round4(end), start: round4(start), beatIndex, target: round4(end), delta: 0 });
336
+ } else {
337
+ const t = t0 + 0.6 * (t1 - t0);
338
+ cues.push({ kind, t: round4(t), beatIndex, target: round4(t), delta: 0, end: round4(beatIndex === beats.length - 1 ? durationSec : t1) });
339
+ }
340
+ }
341
+ });
342
+ for (const e of Array.isArray(events) ? events : []) {
343
+ if (!EVENT_SOUNDS.includes(e?.kind) || !(e.t >= 0 && e.t < durationSec)) continue;
344
+ const beatIndex = Math.max(0, beats.findIndex((b) => e.t >= Number(b.t0) && e.t < Number(b.t1)));
345
+ const cut = nearest(cutTimes, e.t, Infinity);
346
+ 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)) });
347
+ }
348
+ cues.sort((a, b) => a.t - b.t || a.beatIndex - b.beatIndex);
349
+ return cues;
350
+ }
351
+
352
+ export function samplePeakDbfs(channels) {
353
+ let peak = 0;
354
+ for (const c of channels) for (let i = 0; i < c.length; i++) {
355
+ const v = Math.abs(c[i]);
356
+ if (v > peak) peak = v;
357
+ }
358
+ return dbfs(peak);
359
+ }
360
+
361
+ // The shared master stage: look-ahead limiting under the ceiling, then the
362
+ // gain that lands the bus on the target LUFS.
363
+ export function masterTo(L, R) {
364
+ const sampleCount = L.length;
365
+ const ceiling = 10 ** (-2.1 / 20);
366
+ const ahead = Math.round(0.005 * SOUND.sampleRate);
367
+ const release = 1 - Math.exp(-1 / (0.08 * SOUND.sampleRate));
368
+ const q = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 32767) / 32767;
369
+ const render = (gain) => {
370
+ const need = new Float32Array(sampleCount);
371
+ for (let s = 0; s < sampleCount; s++) {
372
+ const a = Math.max(Math.abs(L[s]), Math.abs(R[s])) * gain;
373
+ need[s] = a > ceiling ? ceiling / a : 1;
374
+ }
375
+ const held = new Float32Array(sampleCount);
376
+ const deque = new Int32Array(sampleCount + ahead + 1);
377
+ let head = 0;
378
+ let tail = 0;
379
+ let next = 0;
380
+ for (let s = 0; s < sampleCount; s++) {
381
+ for (; next < Math.min(sampleCount, s + ahead + 1); next++) {
382
+ while (tail > head && need[deque[tail - 1]] >= need[next]) tail--;
383
+ deque[tail++] = next;
384
+ }
385
+ while (deque[head] < s) head++;
386
+ held[s] = need[deque[head]];
387
+ }
388
+ let env = 1;
389
+ const smooth = new Float32Array(sampleCount);
390
+ for (let s = 0; s < sampleCount; s++) {
391
+ env = Math.min(held[s], env + (1 - env) * release);
392
+ smooth[s] = env;
393
+ }
394
+ const l = new Float32Array(sampleCount);
395
+ const r = new Float32Array(sampleCount);
396
+ let win = ahead;
397
+ for (let s = 0; s < sampleCount; s++) {
398
+ win += smooth[s] - (s >= ahead ? smooth[s - ahead] : 1);
399
+ const g = win / ahead;
400
+ l[s] = q(Math.max(-ceiling, Math.min(ceiling, L[s] * gain * g)));
401
+ r[s] = q(Math.max(-ceiling, Math.min(ceiling, R[s] * gain * g)));
402
+ }
403
+ return [l, r];
404
+ };
405
+ const raw = integratedLoudness([Float32Array.from(L), Float32Array.from(R)]);
406
+ let gain = Number.isFinite(raw) ? 10 ** ((SOUND.targetLufs - raw) / 20) : 1;
407
+ let [left, right] = render(gain);
408
+ let lufs = integratedLoudness([left, right]);
409
+ for (let i = 0; i < 8 && Number.isFinite(lufs) && Math.abs(lufs - SOUND.targetLufs) > 0.2; i++) {
410
+ gain *= 10 ** ((SOUND.targetLufs - lufs) / 20);
411
+ [left, right] = render(gain);
412
+ lufs = integratedLoudness([left, right]);
413
+ }
414
+ return { left, right, gain, lufs };
415
+ }
416
+
417
+ // The score writer uses the same 16-bit stereo bytes as the bed writer.
418
+ export const encodeWav = ({ left, right }) => wavBytes(left, right);
419
+
273
420
  // ---------------------------------------------------------------------------
274
421
  // Mux and gate.
275
422
 
@@ -338,6 +485,15 @@ export async function produceSound({ out, treatment, fps, totalFrames, cuts, vid
338
485
  return { summary: { mode, label: 'no sound (silence was asked for, or the channel plays muted)' }, rules: [{ id: 'sound-stream', status: 'PASS', detail: 'no sound planned' }] };
339
486
  }
340
487
  let track, bed = null;
488
+ if (mode === 'score') {
489
+ const { writeScore } = await import('./score.mjs');
490
+ const score = writeScore({ treatment, frameCount: totalFrames, fps, cutTimes: cuts, dir: out });
491
+ track = score.file;
492
+ const label = `score, ${score.genre} in ${score.key}, ${score.tempo} BPM, ${score.stems.filter((s) => s.kind !== 'return').length} stems; ${score.progression}; ${score.lufs.toFixed(1)} LUFS, peak ${score.peakDbfs.toFixed(1)} dBFS — labelled as generated`;
493
+ muxAudio({ video, track, output, durationSec });
494
+ const gate = soundGate({ file: output, mode });
495
+ return { summary: { mode, label, track, score: { sha256: score.sha256, loudnessLufs: score.lufs, peakDbfs: score.peakDbfs, genre: score.genre, key: score.key, tempo: score.tempo }, mux: { peakDbfs: gate.peakDbfs, audioSec: gate.audioSec, videoSec: gate.videoSec } }, rules: gate.rules };
496
+ }
341
497
  if (mode === 'generated') {
342
498
  bed = buildBed({ out, treatment, fps, totalFrames, cuts });
343
499
  track = bed.file;
@@ -360,6 +516,21 @@ export async function soundCommand(flags) {
360
516
  const out = resolve(flags.out || 'motion-output');
361
517
  try {
362
518
  const { treatment } = loadTreatment(out, EXIT.BLOCKED_TREATMENT_INVALID);
519
+ if (treatment.sound.mode === 'score') {
520
+ const { writeScore } = await import('./score.mjs');
521
+ const fps = treatment.fps === 30 ? 30 : 60;
522
+ const manifestFile = join(out, 'manifest.json');
523
+ const manifest = existsSync(manifestFile) ? JSON.parse(readFileSync(manifestFile, 'utf8')) : null;
524
+ const totalFrames = manifest?.totalFrames || Math.round((manifest?.durationSec || treatment.durationSec) * fps);
525
+ const cuts = manifest?.path === 'stage' && manifest.beats ? manifest.beats.slice(1).map((b) => b.first / fps)
526
+ : manifest?.timeline ? manifest.timeline.filter((u) => u.kind === 'line').slice(1).map((u) => u.start)
527
+ : treatment.beats.slice(1).map((b) => b.t0);
528
+ const score = writeScore({ treatment, frameCount: totalFrames, fps: manifest?.fps || fps, cutTimes: cuts, dir: out });
529
+ const sections = score.sections.map((s) => s.kind).join(' → ');
530
+ console.log(`sound: sound/score.wav (score, ${score.genre} in ${score.key}, ${score.tempo} BPM; ${score.progression}; ${sections}; sidechain ${score.sidechainDb.toFixed(1)} dB; ${score.lufs.toFixed(1)} LUFS, peak ${score.peakDbfs.toFixed(1)} dBFS; stems in sound/stems)`);
531
+ console.log(`cues: ${join(out, 'sound-cues.json')} (${score.cues.length} cues)`);
532
+ return 0;
533
+ }
363
534
  if (treatment.sound.mode !== 'generated') {
364
535
  console.log(`sound: the treatment's sound mode is ${treatment.sound.mode}; nothing to generate`);
365
536
  return 0;
@@ -131,6 +131,15 @@ const PAGE_QA = String(function install() {
131
131
  };
132
132
  });
133
133
 
134
+ export function copyOverlaps(boxes) {
135
+ const hits = [];
136
+ for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
137
+ const [ax0, ay0, ax1, ay1] = boxes[i], [bx0, by0, bx1, by1] = boxes[j];
138
+ if (Math.min(ax1, bx1) > Math.max(ax0, bx0) && Math.min(ay1, by1) > Math.max(ay0, by0)) hits.push([i, j]);
139
+ }
140
+ return hits;
141
+ }
142
+
134
143
  function inkMask(a, b, width, height, rects) {
135
144
  const mask = Buffer.alloc(width * height);
136
145
  const inside = new Uint8Array(width * height);
@@ -191,7 +200,7 @@ export async function runTextQa(session) {
191
200
  const tenFps = Math.max(1, Math.round(fps / 10));
192
201
  const visibleSpans = new Map();
193
202
  const seenCopy = new Set();
194
- const results = { contrast: [], safe: [], decorCopy: [], decorArea: [], meta: new Set(), fonts: [], moved: [], canvasUnmeasured: false, nonText: [], floors: [] };
203
+ const results = { contrast: [], safe: [], decorCopy: [], decorArea: [], meta: new Set(), fonts: [], moved: [], canvasUnmeasured: false, nonText: [], floors: [], overlap: [] };
195
204
  const stage = await session.open('q');
196
205
  const cdp = stage.cdp;
197
206
  const PNG = session.PNG;
@@ -215,7 +224,9 @@ export async function runTextQa(session) {
215
224
  const now = new Map();
216
225
  for (const run of runs) {
217
226
  const visible = run.opacity >= 0.6 && run.rects.length > 0;
218
- const key = `${run.norm}|${run.bucket}`;
227
+ // The floor span is the copy line's, not its position bucket's: a line
228
+ // that slides (a push handoff moves its box) keeps one continuous span.
229
+ const key = run.norm;
219
230
  now.set(run.id, run);
220
231
  if (!visible || !run.norm) continue;
221
232
  if (isCopy(run.norm)) for (const c of copyLines) if (c.norm.includes(run.norm) || run.norm.includes(c.norm)) seenCopy.add(c.line);
@@ -268,6 +279,12 @@ export async function runTextQa(session) {
268
279
  }
269
280
  if (textArea > 0 && decorArea / textArea > 0.25) results.decorArea.push(`${Math.round((100 * decorArea) / textArea)} % at frame ${f}`);
270
281
  if (sample.kind === 'mid') results.nonText.push(lumStdOutside(b, W, H, visibleRuns.flatMap((r) => r.rects)));
282
+ if (sample.kind === 'settled') {
283
+ const settledCopy = visibleRuns.filter((r) => !r.decor && r.opacity >= 0.95 && isCopy(r.norm));
284
+ for (const [i, j] of copyOverlaps(settledCopy.map((r) => r.box))) {
285
+ results.overlap.push(`"${settledCopy[i].text.slice(0, 30)}" over "${settledCopy[j].text.slice(0, 30)}" at frame ${f}`);
286
+ }
287
+ }
271
288
  }
272
289
  prevRuns = now;
273
290
  }
@@ -287,7 +304,7 @@ export async function runTextQa(session) {
287
304
  if (missing.length) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: copy line not on screen: "${missing[0].line}"${missing.length > 1 ? ` (+${missing.length - 1} more)` : ''}; every copy.lines entry must appear as text the QA can read`);
288
305
 
289
306
  const rules = [];
290
- const row = (id, status, detail) => rules.push({ id, status, detail });
307
+ const row = (id, status, detail) => { rules.push({ id, status, detail }); if (!QA_ROW_IDS.includes(id)) QA_ROW_IDS.push(id); };
291
308
  const lowCopy = results.contrast.filter((c) => !c.decor && c.ratio < c.floor);
292
309
  const lowDecor = results.contrast.filter((c) => c.decor && c.ratio < c.floor);
293
310
  const worst = results.contrast.reduce((m, c) => (m && m.ratio / m.floor <= c.ratio / c.floor ? m : c), null);
@@ -307,9 +324,12 @@ export async function runTextQa(session) {
307
324
  row('qa-samples', results.moved.length ? 'WARN' : 'PASS', results.moved.length ? `state moved while the text was hidden at frames ${results.moved.slice(0, 6).join(', ')}; those samples were discarded` : `${samples.size} samples measured`);
308
325
  const lively = results.nonText.filter((s) => s > 8 / 255).length;
309
326
  row('non-text-presence', results.nonText.length && lively * 2 >= results.nonText.length ? 'PASS' : 'WARN', `${lively} of ${results.nonText.length} beat midpoints show a non-text image (luminance spread over 8/255 outside the text)`);
327
+ row('text-overlap', results.overlap.length ? 'FAIL' : 'PASS', results.overlap.length ? `settled copy overlaps: ${results.overlap[0]}` : 'no two settled copy lines overlap');
310
328
  return { rules, summary: { samples: [...samples.keys()].sort((a, b) => a - b), contrast: results.contrast.length, copySeen: [...seenCopy], meta: [...results.meta] } };
311
329
  }
312
330
 
331
+ export const QA_ROW_IDS = ['text-contrast', 'text-title-safe', 'text-reading-floor', 'copy-found', 'decor-copy', 'decor-area', 'meta-label', 'fonts', 'canvas-text', 'qa-samples', 'non-text-presence', 'text-overlap'];
332
+
313
333
  // The platform names of this product's verified faces, read from probe spans.
314
334
  async function faceNames(stage) {
315
335
  await stage.page.evaluate(() => {
@@ -8,20 +8,39 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSy
8
8
  import { dirname, extname, join, relative, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { Worker } from 'node:worker_threads';
11
- import { ALWAYS_FLAGS, EXIT, OUTPUT } from './constants.mjs';
11
+ import { ALWAYS_FLAGS, EXIT, OUTPUT, chromeFlagLadder } from './constants.mjs';
12
12
  import { chromeExecutable, launchCause, profileSockets } from './browser.mjs';
13
13
  import { FlashDetector } from './flash.mjs';
14
14
  import { encodePreview, ffprobe, hasFfmpeg, pngEncode, startMaster } from './encode.mjs';
15
15
  import { FORMATS, loadTreatment } from './treatment.mjs';
16
+ import { readPlate } from './blender.mjs';
16
17
  import { recordFirstTreatment, writeJson, writeRunState } from './director.mjs';
17
18
  import { stampStills, stillsPlan, writeStillsSet } from './stills.mjs';
18
19
  import { runStageGate, renderStageReport } from './stage-gate.mjs';
19
20
  import { produceSound, soundGate } from './sound.mjs';
20
21
  import { runTextQa } from './stage-qa.mjs';
22
+ import { readStyle, styleCss, styleScript } from './styles.mjs';
23
+ import { addToStills, writeBoard } from './board.mjs';
21
24
  import { FONT_FILES } from './frame.mjs';
25
+
22
26
  import { findCache, fontFile, fontState, hangulPath, runtimeRequire, WARM } from '../bin/runtime.mjs';
23
27
 
24
28
  const here = dirname(fileURLToPath(import.meta.url));
29
+ // The pinned three.js build and its two addons (engine/vendor/three, MIT),
30
+ // served to stage pages at /lit/three/; anything else under that prefix is refused.
31
+ const THREE_DIR = join(here, 'vendor', 'three');
32
+
33
+ // Resolve a /lit/three/ request to a vendored file: only .js paths without ..
34
+ // that exist under engine/vendor/three. Anything else is refused, never served.
35
+ export function threeFile(pathname) {
36
+ const rel = String(pathname).slice('/lit/three/'.length);
37
+ if (rel.split('/').includes('..') || !rel.endsWith('.js')) return { status: 404, reason: `request for ${pathname} is not a vendored three.js file` };
38
+ let real = null;
39
+ try { real = realpathSync(join(THREE_DIR, rel)); } catch { return { status: 404, reason: `request for ${pathname} is not a vendored three.js file` }; }
40
+ const root = realpathSync(THREE_DIR);
41
+ if (real !== root && !real.startsWith(root + sep)) return { status: 404, reason: `request for ${pathname} is not a vendored three.js file` };
42
+ return { status: 200, file: real };
43
+ }
25
44
  export const ORIGIN = 'http://lit.stage';
26
45
  const sha = (data) => createHash('sha256').update(data).digest('hex');
27
46
  const fail = (code, message, extra = {}) => Object.assign(new Error(message), { exitCode: code, ...extra });
@@ -39,6 +58,19 @@ export const RASTER_LIMITS = Object.freeze({ count: 24, bytes: 8 * 1024 * 1024,
39
58
  // The software rung only (SwiftShader, CPU raster): a mismatch between two
40
59
  // captures can then only come from the page. Plus the stage set and the two
41
60
  // keychain flags every Chrome launch carries.
61
+ // The 3D route's flag set: the platform's GPU rung first, then the software
62
+ // rung; the determinism flags after the rung are the same as the software rung's.
63
+ export function gpuStageFlags({ width, height }, platform = process.platform) {
64
+ return [...chromeFlagLadder(platform)[0], ...stageFlags({ width, height }).slice(3)];
65
+ }
66
+
67
+ // The browser rung: a three.js world asks for the GPU; anything else (a 2D
68
+ // film, or a Blender film that shows its path-traced plate under HTML type)
69
+ // needs only the software rung.
70
+ export function stageRung(treatment) {
71
+ return treatment?.depth === '3d' && treatment?.render3d !== 'blender' ? 'gpu' : 'software';
72
+ }
73
+
42
74
  export function stageFlags({ width, height }) {
43
75
  return [
44
76
  '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-rasterization',
@@ -221,25 +253,62 @@ export function stageFile(root, pathname) {
221
253
  return { status: 200, file: real, type };
222
254
  }
223
255
 
224
- export async function openStage({ runtime, stageDir, profileRoot, width, height, seed, fontCss, fontFiles, env = process.env }) {
256
+ const webglProbe = () => {
257
+ const canvas = document.createElement('canvas');
258
+ const gl = canvas.getContext('webgl2');
259
+ if (!gl) return { webgl2: false, renderer: null };
260
+ const ext = gl.getExtension('WEBGL_debug_renderer_info');
261
+ return { webgl2: true, renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null };
262
+ };
263
+
264
+ export async function openStage({ runtime, stageDir, profileRoot, width, height, seed, fontCss, fontFiles, style = null, rung = 'software', platform = process.platform, env = process.env }) {
225
265
  const chrome = chromeExecutable(env);
226
266
  if (!chrome) throw fail(EXIT.BLOCKED_NO_CHROME, 'BLOCKED_NO_CHROME: Chrome/Chromium not found; install Chrome or set CHROME_PATH');
227
267
  const { chromium } = runtime('playwright-core');
228
- const flags = stageFlags({ width, height });
229
268
  mkdirSync(profileRoot, { recursive: true });
230
269
  const profile = join(profileRoot, 'p');
270
+ const launch = (flags) => chromium.launchPersistentContext(profile, {
271
+ executablePath: chrome, headless: true, chromiumSandbox: true, args: flags, timeout: 30000,
272
+ viewport: { width, height }, deviceScaleFactor: 1, serviceWorkers: 'block',
273
+ });
274
+ const notices = [];
275
+ let flags = rung === 'gpu' ? gpuStageFlags({ width, height }, platform) : stageFlags({ width, height });
276
+ let used = rung;
231
277
  let context;
232
278
  try {
233
- context = await chromium.launchPersistentContext(profile, {
234
- executablePath: chrome, headless: true, chromiumSandbox: true, args: flags, timeout: 30000,
235
- viewport: { width, height }, deviceScaleFactor: 1, serviceWorkers: 'block',
236
- });
279
+ context = await launch(flags);
237
280
  } catch (error) {
238
- rmSync(profile, { recursive: true, force: true });
239
- throw fail(EXIT.BLOCKED_NO_CHROME, `BLOCKED_NO_CHROME: headless Chrome failed to launch on the software rung: ${launchCause(error)}`);
281
+ if (rung !== 'gpu') {
282
+ rmSync(profile, { recursive: true, force: true });
283
+ throw fail(EXIT.BLOCKED_NO_CHROME, `BLOCKED_NO_CHROME: headless Chrome failed to launch on the software rung: ${launchCause(error)}`);
284
+ }
285
+ notices.push(`the 3D route asked for the GPU rung and it did not start (${launchCause(error).slice(0, 120)}); rendered on the software rung (SwiftShader), slower, same frames`);
286
+ flags = stageFlags({ width, height });
287
+ used = 'software';
288
+ try {
289
+ context = await launch(flags);
290
+ } catch (retry) {
291
+ rmSync(profile, { recursive: true, force: true });
292
+ throw fail(EXIT.BLOCKED_NO_CHROME, `BLOCKED_NO_CHROME: headless Chrome failed to launch on both rungs: ${launchCause(retry)}`);
293
+ }
240
294
  }
241
- const page = context.pages()[0] || await context.newPage();
295
+ let page = context.pages()[0] || await context.newPage();
242
296
  page.setDefaultTimeout(0);
297
+ const probe = await page.evaluate(webglProbe).catch(() => ({ webgl2: false, renderer: null }));
298
+ if (used === 'gpu' && !probe.webgl2) {
299
+ notices.push('the 3D route asked for the GPU rung and the page got no WebGL there; rendered on the software rung (SwiftShader), slower, same frames');
300
+ await context.close().catch(() => {});
301
+ flags = stageFlags({ width, height });
302
+ used = 'software';
303
+ try {
304
+ context = await launch(flags);
305
+ } catch (error) {
306
+ rmSync(profile, { recursive: true, force: true });
307
+ throw fail(EXIT.BLOCKED_NO_CHROME, `BLOCKED_NO_CHROME: headless Chrome failed to launch on the software rung: ${launchCause(error)}`);
308
+ }
309
+ page = context.pages()[0] || await context.newPage();
310
+ page.setDefaultTimeout(0);
311
+ }
243
312
  const cdp = await context.newCDPSession(page);
244
313
  const root = realpathSync(stageDir);
245
314
  const kit = readFileSync(join(here, 'stage-kit.js'));
@@ -260,6 +329,16 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
260
329
  const path = decodeURIComponent(url.pathname);
261
330
  if (path === '/lit/stage-kit.js') return reply(200, CONTENT_TYPES['.js'], kit);
262
331
  if (path === '/lit/fonts.css') return reply(200, CONTENT_TYPES['.css'], fontCss);
332
+ if (path === '/lit/style.css' && style) return reply(200, CONTENT_TYPES['.css'], style.css);
333
+ if (path === '/lit/style.js' && style) return reply(200, CONTENT_TYPES['.js'], style.js);
334
+ if (path.startsWith('/lit/three/')) {
335
+ const found = threeFile(path);
336
+ if (found.status !== 200) {
337
+ contract.push(found.reason);
338
+ return reply(404, 'text/plain', 'not found');
339
+ }
340
+ return reply(200, CONTENT_TYPES['.js'], readFileSync(found.file));
341
+ }
263
342
  if (fontFiles.has(path)) return reply(200, CONTENT_TYPES[extname(path).toLowerCase()] || 'font/ttf', readFileSync(fontFiles.get(path)));
264
343
  const found = stageFile(root, path);
265
344
  if (found.status === 403) { contract.push(found.reason); return reply(403, 'text/plain', 'forbidden'); }
@@ -272,7 +351,7 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
272
351
  await page.addInitScript({ content: `try { ${init.trim()}(${JSON.stringify({ seed, epochMs: 1767225600000 })}); } catch (error) { window.__litStageInitError = String(error && error.stack || error); }` });
273
352
  const sockets = profileSockets(profile);
274
353
  return {
275
- page, cdp, flags, blocked, contract, sockets,
354
+ page, cdp, flags, blocked, contract, sockets, rung: used, renderer: probe.renderer, notices,
276
355
  async load() {
277
356
  await page.goto(`${ORIGIN}/index.html`, { waitUntil: 'load', timeout: 120000 });
278
357
  return page.evaluate(() => {
@@ -437,12 +516,22 @@ class StageSession {
437
516
  this.PNG = this.require('pngjs').PNG;
438
517
  this.fonts = fontRoutes(this.cache);
439
518
  this.ffmpeg = hasFfmpeg();
519
+ const filmStyle = readStyle(this.treatment.style);
520
+ this.style = { css: styleCss(filmStyle, this.treatment.format), js: styleScript(filmStyle, this.treatment.format, this.treatment) };
521
+ this.rung = stageRung(this.treatment);
522
+ // A Blender film shows its path-traced plate under HTML type, so it shares the page's
523
+ // run with a 3-D-of-any-kind page: render the plate first, blender --out DIR --final
524
+ // (no Blender: set render3d to three and use the GPU rung).
525
+ if (this.treatment.depth === '3d' && this.treatment.render3d === 'blender' && !readPlate(this.stageDir)) {
526
+ throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: render3d is blender but stage/plate/plate.json is missing or does not match its frames; render the plate first: blender --out ${this.out} --final (no Blender: set render3d to three and use the GPU rung)`);
527
+ }
440
528
  }
441
529
 
442
- open(tag) {
530
+ open(tag, { stageDir = this.stageDir, width = this.size[0], height = this.size[1] } = {}) {
443
531
  return openStage({
444
- runtime: this.require, stageDir: this.stageDir, profileRoot: join(this.out, '.run', `${tag}${process.pid % 100000}`),
445
- width: this.size[0], height: this.size[1], seed: this.seed, fontCss: this.fonts.css, fontFiles: this.fonts.routes,
532
+ runtime: this.require, stageDir, profileRoot: join(this.out, '.run', `${tag}${process.pid % 100000}`),
533
+ width, height, seed: this.seed, fontCss: this.fonts.css, fontFiles: this.fonts.routes, style: this.style,
534
+ rung: this.rung || 'software',
446
535
  });
447
536
  }
448
537
 
@@ -576,7 +665,10 @@ class StageSession {
576
665
  schemaVersion: 2, path: 'stage', format: this.treatment.format, fps: this.fps, resolution: [W, H], durationSec: this.durationSec,
577
666
  targetDurationSec: this.treatment.durationSec, totalFrames: this.totalFrames, round: this.round, mode: this.mode,
578
667
  treatment: { file: join(this.out, 'treatment.json'), sha256: sha(readFileSync(join(this.out, 'treatment.json'))) },
579
- chromeFlags: this.stage?.flags || [], chromeSockets: this.stage?.sockets || [], renderer: 'SwiftShader (software rung)',
668
+ chromeFlags: this.stage?.flags || [], chromeSockets: this.stage?.sockets || [],
669
+ rung: this.stage?.rung || this.rung || 'software',
670
+ renderer: this.stage?.rung === 'gpu' ? (this.stage?.renderer || 'GPU rung') : 'SwiftShader (software rung)',
671
+ rungNotices: this.stage?.notices || [],
580
672
  faces: this.faces, scan: this.scan, beats: this.beats, stills: this.plan, determinism: this.determinismResult || null,
581
673
  frameTimeMs: { p50: pick(0.5), p95: pick(0.95), captured: timing.length }, masterSec: this.masterSec, warnings: this.warnings,
582
674
  generatedAt: new Date().toISOString(), ...extra,
@@ -597,9 +689,24 @@ export async function stageCommand(flags) {
597
689
  writeJson(join(session.out, 'manifest.json'), manifest);
598
690
  stampStills(session.out);
599
691
  if (!session.ffmpeg && !session.stillsOnly) throw fail(EXIT.BLOCKED_NO_FFMPEG_FOR_VIDEO, `BLOCKED_NO_FFMPEG_FOR_VIDEO: stills and the contact sheet are in ${session.out}; ffmpeg is not on PATH, so no film was encoded`);
692
+ // The board round: the text and style rules run on the board frames, the storyboard is
693
+ // drawn with the verdict on it, and a FAIL ends the round here (exit 13).
694
+ const qa = await runTextQa(session);
695
+ writeJson(join(session.out, 'stills', 'check.json'), { rules: qa.rules, summary: qa.summary });
696
+ const beatFiles = stills.files.filter((f) => f.kind === 'beat-mid').sort((a, b) => a.beat - b.beat).map((f) => join(session.out, f.file));
697
+ const boardFile = await writeBoard({
698
+ out: session.out, treatment: session.treatment, beatFiles, check: { rules: qa.rules },
699
+ open: (opts) => session.open('b', opts),
700
+ });
701
+ const boardSha = addToStills(session.out, { file: 'stills/board.png', kind: 'board', frames: session.beats.map((b) => b.index) }, { sha256: sha });
702
+ const failed = qa.rules.filter((rule) => rule.status === 'FAIL');
703
+ if (failed.length) {
704
+ writeRunState(session.out, { exitCode: EXIT.GATE_FAIL_QA, mode: session.mode, round: session.round, finished: false, failed: failed.map((r) => r.id), path: 'stage' });
705
+ throw fail(EXIT.GATE_FAIL_QA, `BOARD_CHECK_FAIL: ${failed.map((r) => `${r.id} ${r.detail}`).join('; ')}. Fix the page, render --stills-only again, then look at the board ${boardFile} and every file in ${join(session.out, 'stills')}`);
706
+ }
600
707
  writeRunState(session.out, { exitCode: 0, mode: session.mode, round: session.round, finished: true, failed: [], path: 'stage' });
601
- console.log(`stills: ${stills.files.map((f) => join(session.out, f.file)).join(' ')}`);
602
- console.log('View every still, strip and the contact sheet, then record the round with `look` before the full render.');
708
+ console.log(`stills: ${stills.files.map((f) => join(session.out, f.file)).join(' ')} board ${boardFile} (stills index ${boardSha.slice(0, 12)})`);
709
+ console.log(`STILLS ONLY: board check PASS; open the storyboard ${boardFile} and every file in ${join(session.out, 'stills')}, record the round with look --round ${session.round}, make the change, then render the film with --round ${session.round + 1} (no --stills-only).`);
603
710
  return 0;
604
711
  }
605
712
  await session.determinism();