@litfamily/litmuse 0.1.1 → 0.1.2

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 (168) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -0
  3. package/README_ko-KR.md +20 -0
  4. package/bin/litmuse.mjs +4 -0
  5. package/lib/stage-card.mjs +180 -5
  6. package/package.json +1 -1
  7. package/plugin/.muse-plugin/plugin.json +26 -11
  8. package/plugin/commands/lit-status.md +2 -1
  9. package/plugin/commands/lit.md +1 -1
  10. package/plugin/hooks/lib/digest.mjs +1 -1
  11. package/plugin/hooks/lib/next-step.mjs +68 -0
  12. package/plugin/hooks/lib/routes.mjs +59 -0
  13. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  14. package/plugin/hooks/session-start.mjs +29 -2
  15. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  16. package/plugin/lib/capped-fetch.mjs +25 -0
  17. package/plugin/lib/pinned-fetch.mjs +79 -0
  18. package/plugin/lib/plan-task-rows.mjs +32 -0
  19. package/plugin/lib/render_pages.py +29 -1
  20. package/plugin/lib/rewind/collect.mjs +679 -0
  21. package/plugin/lib/rewind/gauge.mjs +72 -0
  22. package/plugin/lib/rewind/next-step.mjs +91 -0
  23. package/plugin/lib/rewind/redact.mjs +83 -0
  24. package/plugin/lib/rewind/transcripts.mjs +227 -0
  25. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  26. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  27. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  28. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  29. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  30. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  31. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  32. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  33. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  34. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  35. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  36. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  37. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  38. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  39. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  40. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  41. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  42. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  43. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  44. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  45. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  46. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  47. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  48. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  50. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  51. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  52. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  53. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  54. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  55. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  56. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  57. package/plugin/skills/lit-docx/SKILL.md +1 -1
  58. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  59. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  60. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  61. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  62. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  63. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  64. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  65. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  66. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  67. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  68. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  69. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  70. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  71. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  72. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  73. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  74. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  75. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  76. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  77. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  78. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  79. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  80. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  81. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  82. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  83. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  84. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  85. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  86. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  87. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  88. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  89. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  90. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  91. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  92. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  93. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  94. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  95. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  96. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  97. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  100. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  101. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  102. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  103. package/plugin/skills/lit-recap/SKILL.md +3 -0
  104. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  105. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  106. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  107. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  108. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  109. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  110. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  111. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  112. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  113. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  114. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  115. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  116. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  117. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  118. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  119. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  120. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  121. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  122. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  123. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  126. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  127. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  128. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  129. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  130. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  131. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  132. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  133. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  134. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  135. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  136. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  137. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  138. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  139. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  140. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  141. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  142. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  143. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  145. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  146. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  147. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  148. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  149. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  151. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  152. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  153. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  154. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  155. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  156. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  157. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  158. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  159. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  160. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  161. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  162. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  163. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  164. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  165. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  166. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  167. package/plugin/skills/readme-studio/SKILL.md +1 -1
  168. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -1,12 +1,14 @@
1
- // `motion.mjs sound --out <dir>`: build the generated bed from <dir>/treatment.json so it can be
2
- // heard and its cues checked before the full render. The render rebuilds the same bed at the film's
3
- // exact frame count and cut times, so the muxed track always matches the picture.
1
+ // `motion.mjs sound --out <dir>`: build the film's score (or a minimal film's generated bed) from
2
+ // <dir>/treatment.json so it can be heard and its cues checked before the full render. The render
3
+ // rebuilds the same track at the film's exact frame count and cut times, so the muxed track always
4
+ // matches the picture.
4
5
  import { existsSync } from "node:fs";
5
6
  import path from "node:path";
6
7
  import { EXIT } from "../core/constants.mjs";
7
8
  import { loadBrief, planFromBrief } from "./brief.mjs";
8
9
  import { BlockedError } from "./chrome.mjs";
9
10
  import { asBlocked } from "./pipeline.mjs";
11
+ import { writeScore } from "./score.mjs";
10
12
  import { prepareTrack, writeBed } from "./sound.mjs";
11
13
  import { loadTreatment } from "./treatment.mjs";
12
14
 
@@ -21,8 +23,19 @@ export function soundTimeline(out, treatment) {
21
23
  return { fps, frameCount: Math.round(treatment.durationSec * fps), cutTimes: treatment.beats.slice(1).map((b) => b.t0) };
22
24
  }
23
25
 
26
+ const scoreLabel = (score) => `score, ${score.genre} in ${score.key}, ${score.tempo} BPM, ${score.stems.filter((x) => x.kind !== "return").length} stems`;
27
+
24
28
  export async function soundCommand({ out }) {
25
29
  const treatment = asBlocked(() => loadTreatment(out));
30
+ if (treatment.sound.mode === "score") {
31
+ const { fps, frameCount, cutTimes } = soundTimeline(out, treatment);
32
+ const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
33
+ const sections = score.sections.map((x) => x.kind).join(" → ");
34
+ return {
35
+ code: EXIT.OK,
36
+ message: `SOUND: ${scoreLabel(score)} (${score.progression}; ${sections}; sidechain ${score.sidechainDb.toFixed(1)} dB; ${score.lufs.toFixed(1)} LUFS, peak ${score.peakDbfs.toFixed(1)} dBFS) in ${score.file}; stems in ${path.join(out, "sound", "stems")}; ${score.cues.length} cues in ${path.join(out, "sound-cues.json")}`,
37
+ };
38
+ }
26
39
  if (treatment.sound.mode !== "generated") {
27
40
  return { code: EXIT.OK, message: `SOUND: mode ${treatment.sound.mode}; nothing to generate (the render ${treatment.sound.mode === "none" ? "adds no track" : "prepares and muxes the track"})` };
28
41
  }
@@ -35,13 +48,17 @@ export async function soundCommand({ out }) {
35
48
  }
36
49
 
37
50
  /**
38
- * The film's track on either path: the generated bed at the film's exact frame count and cuts, a
51
+ * The film's track on either path: the score or the generated bed at the film's exact frame count and cuts, a
39
52
  * supplied or authored track padded or trimmed to the film, or none. Always muxed whatever the
40
53
  * audio-analysis tier's state (the Wave 1 renderer dropped a track when that tier was absent).
41
54
  */
42
55
  export function soundTrackFor({ treatment, out, stageDir = null, frameCount, fps, cutTimes, ffmpeg }) {
43
56
  const mode = treatment.sound.mode;
44
57
  if (mode === "none") return { mode, file: null, label: "none (asked for)" };
58
+ if (mode === "score") {
59
+ const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
60
+ return { mode, file: score.file, label: scoreLabel(score), bed: score };
61
+ }
45
62
  if (mode === "generated") {
46
63
  const bed = writeBed({ treatment, frameCount, fps, cutTimes, dir: out });
47
64
  return { mode, file: path.join(out, "sound", "bed.wav"), label: `generated bed, ${bed.timbre} in ${bed.key}, ${bed.tempo} BPM`, bed };
@@ -28,6 +28,7 @@ export const SOUND = Object.freeze({
28
28
  fadeSec: 0.05,
29
29
  });
30
30
 
31
+ export { TABLE as WAVETABLE_SIZE };
31
32
  export const TEMPO_RANGE = Object.freeze([60, 160]);
32
33
  const PITCH = { C: 0, "C#": 1, Db: 1, D: 2, "D#": 3, Eb: 3, E: 4, F: 5, "F#": 6, Gb: 6, G: 7, "G#": 8, Ab: 8, A: 9, "A#": 10, Bb: 10, B: 11 };
33
34
  export const KEYS = Object.freeze({ pitchClasses: Object.freeze(Object.keys(PITCH)), modes: Object.freeze(["major", "minor"]) });
@@ -86,6 +87,13 @@ export function normalizePalette(palette) {
86
87
  return { timbre, key, mode, tempo };
87
88
  }
88
89
 
90
+ /**
91
+ * Sounds an on-screen event can make (treatment `sound.events`): a tap (a small click with a pitched
92
+ * body: a key, a latch, a drop landing), a hit (the cut hit), a whoosh (air ending on the event) and a
93
+ * chime (two notes of the key: something completes).
94
+ */
95
+ export const EVENT_SOUNDS = Object.freeze(["tap", "hit", "whoosh", "chime"]);
96
+
89
97
  const CUE_WORDS = [
90
98
  ["hit", /\b(?:hits?|impacts?|cuts?|stabs?|accents?)\b|타격|히트|컷|강세/iu],
91
99
  ["rise", /\b(?:rises?|rising|risers?|swells?|swelling|builds?|building|build-?ups?|lifts?|lifting)\b|상승|고조|빌드업/iu],
@@ -104,8 +112,11 @@ const nearest = (times, t, within) => {
104
112
  return best;
105
113
  };
106
114
 
107
- /** Accent cues, each measured against the beat boundary or cut it serves. */
108
- export function planCues({ beats = [], durationSec, cutTimes = [] }) {
115
+ /**
116
+ * Accent cues, each measured against the beat boundary or cut it serves, plus one `event` cue per
117
+ * on-screen event at its own time (it never snaps to a cut: it follows the picture).
118
+ */
119
+ export function planCues({ beats = [], durationSec, cutTimes = [], events = [] }) {
109
120
  const cues = [];
110
121
  const clampT = (t) => Math.min(durationSec, Math.max(0, t));
111
122
  beats.forEach((beat, beatIndex) => {
@@ -127,6 +138,12 @@ export function planCues({ beats = [], durationSec, cutTimes = [] }) {
127
138
  }
128
139
  }
129
140
  });
141
+ for (const e of Array.isArray(events) ? events : []) {
142
+ if (!EVENT_SOUNDS.includes(e?.kind) || !(e.t >= 0 && e.t < durationSec)) continue;
143
+ const beatIndex = Math.max(0, beats.findIndex((b) => e.t >= Number(b.t0) && e.t < Number(b.t1)));
144
+ const cut = nearest(cutTimes, e.t, Infinity);
145
+ 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)) });
146
+ }
130
147
  cues.sort((a, b) => a.t - b.t || a.beatIndex - b.beatIndex);
131
148
  return cues;
132
149
  }
@@ -264,7 +281,8 @@ export function decodeWav(buffer) {
264
281
 
265
282
  // ---- synthesis ----
266
283
  const TABLE = 4096;
267
- function wavetable(harmonics) {
284
+ /** A normalized single-cycle table of additive partials (TABLE + 1 samples, wrapped). */
285
+ export function wavetable(harmonics) {
268
286
  const t = new Float32Array(TABLE + 1);
269
287
  let peak = 0;
270
288
  for (let i = 0; i < TABLE; i++) {
@@ -316,7 +334,7 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
316
334
  const sampleCount = Math.round((frameCount * sr) / fps);
317
335
  const durationSec = sampleCount / sr;
318
336
  const beats = Array.isArray(treatment?.beats) ? treatment.beats : [];
319
- const cues = planCues({ beats, durationSec, cutTimes });
337
+ const cues = planCues({ beats, durationSec, cutTimes, events: treatment?.sound?.events });
320
338
  const rand = mulberry32(fnv1a32(`${treatment?.request ?? ""}|${palette.timbre}|${palette.key}|${palette.mode}|${palette.tempo}`));
321
339
  const L = new Float64Array(sampleCount);
322
340
  const R = new Float64Array(sampleCount);
@@ -445,22 +463,58 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
445
463
  }
446
464
  }
447
465
  };
448
- for (const cue of cues) {
449
- if (cue.kind === "hit") {
450
- const s0 = Math.round(cue.t * sr);
451
- const len = Math.min(sampleCount - s0, Math.round(0.6 * sr));
466
+ // An on-screen event: a sound shaped like what the picture does at that frame.
467
+ const eventSound = (cue) => {
468
+ const s0 = Math.round(cue.t * sr);
469
+ if (cue.sound === "tap") {
470
+ const len = Math.min(sampleCount - s0, Math.round(0.3 * sr));
471
+ const hz = midiHz(tonic + 24 + (palette.mode === "major" ? 7 : 3));
452
472
  let f = 0;
453
- const c = onePole(voice.accent.kind === "bell" ? 6000 : 1800, sr);
454
- let ph = 0;
473
+ const c = onePole(4500, sr);
455
474
  for (let i = 0; i < len; i++) {
456
475
  const t = i / sr;
457
476
  f += c * (rand() * 2 - 1 - f);
458
- ph += (2 * Math.PI * (50 + 90 * Math.exp(-t / 0.02))) / sr;
459
- const v = 0.55 * Math.exp(-t / 0.12) * f + 0.5 * Math.exp(-t / 0.22) * Math.sin(ph);
477
+ const v = 1.2 * Math.exp(-t / 0.008) * f + 0.6 * Math.exp(-t / 0.07) * Math.sin(2 * Math.PI * hz * t);
460
478
  L[s0 + i] += v;
461
- R[s0 + i] += v;
479
+ R[s0 + i] += v * 0.9;
480
+ }
481
+ } else if (cue.sound === "whoosh") {
482
+ const start = Math.max(0, s0 - Math.round(0.4 * sr));
483
+ const tail = Math.min(sampleCount, s0 + Math.round(0.08 * sr));
484
+ let f = 0;
485
+ for (let s = start; s < tail; s++) {
486
+ const u = s < s0 ? (s - start) / Math.max(1, s0 - start) : 1 - (s - s0) / Math.max(1, tail - s0);
487
+ f += onePole(300 + 3200 * u, sr) * (rand() * 2 - 1 - f);
488
+ const v = 0.5 * u * u * f;
489
+ L[s] += v * (1 - 0.4 * u);
490
+ R[s] += v * (0.6 + 0.4 * u);
462
491
  }
463
- if (voice.accent.kind === "bell") strike(cue.t, [tonic + 12], 0.6, 0.12);
492
+ } else if (cue.sound === "chime") {
493
+ strike(cue.t, [tonic + 12], 1.2, 0.09);
494
+ strike(cue.t + 0.09, [tonic + 19], 1.2, 0.08);
495
+ } else {
496
+ hitAt(cue.t);
497
+ }
498
+ };
499
+ const hitAt = (t0) => {
500
+ const s0 = Math.round(t0 * sr);
501
+ const len = Math.min(sampleCount - s0, Math.round(0.6 * sr));
502
+ let f = 0;
503
+ const c = onePole(voice.accent.kind === "bell" ? 6000 : 1800, sr);
504
+ let ph = 0;
505
+ for (let i = 0; i < len; i++) {
506
+ const t = i / sr;
507
+ f += c * (rand() * 2 - 1 - f);
508
+ ph += (2 * Math.PI * (50 + 90 * Math.exp(-t / 0.02))) / sr;
509
+ const v = 0.55 * Math.exp(-t / 0.12) * f + 0.5 * Math.exp(-t / 0.22) * Math.sin(ph);
510
+ L[s0 + i] += v;
511
+ R[s0 + i] += v;
512
+ }
513
+ if (voice.accent.kind === "bell") strike(t0, [tonic + 12], 0.6, 0.12);
514
+ };
515
+ for (const cue of cues) {
516
+ if (cue.kind === "hit") {
517
+ hitAt(cue.t);
464
518
  } else if (cue.kind === "rise") {
465
519
  const s0 = Math.round(cue.start * sr);
466
520
  const s1 = Math.min(sampleCount, Math.round(cue.t * sr));
@@ -475,6 +529,8 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
475
529
  L[s] += v * (1 - 0.3 * u);
476
530
  R[s] += v * (0.7 + 0.3 * u);
477
531
  }
532
+ } else if (cue.kind === "event") {
533
+ eventSound(cue);
478
534
  } else {
479
535
  const third = palette.mode === "major" ? 4 : 3;
480
536
  strike(cue.t, [tonic - 12, tonic, tonic + third, tonic + 7], voice.accent.decay, 0.1);
@@ -489,20 +545,61 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
489
545
  R[sampleCount - 1 - i] *= g;
490
546
  }
491
547
 
492
- // Loudness to -16 LUFS through a soft limiter under -2 dBFS; quantized as the WAV will be.
548
+ const { left, right, lufs } = masterTo(L, R);
549
+ return { left, right, sampleCount, palette, cues, lufs: +lufs.toFixed(2), peakDbfs: +samplePeakDbfs([left, right]).toFixed(2), loudnessMethod: "BS.1770-4" };
550
+ }
551
+
552
+ /**
553
+ * The master stage every built track ends in: loudness to -16 LUFS through a lookahead limiter under
554
+ * -2 dBFS, quantized as the WAV will be. Returns the channels, the gain applied and the loudness.
555
+ *
556
+ * The limiter turns the gain down smoothly before a peak (5 ms lookahead, both channels linked, 80 ms
557
+ * release) instead of bending the waveform. A waveshaper adds harmonics the AAC encode turns into
558
+ * overshoot: one film's score sat at -2.1 dBFS in the WAV and decoded at +0.4 dBFS from the mux.
559
+ */
560
+ export function masterTo(L, R) {
561
+ const sampleCount = L.length;
493
562
  const ceiling = 10 ** (-2.1 / 20);
494
- const knee = 0.5 * ceiling;
495
- const limit = (x) => {
496
- const a = Math.abs(x);
497
- return a <= knee ? x : Math.sign(x) * (knee + (ceiling - knee) * Math.tanh((a - knee) / (ceiling - knee)));
498
- };
563
+ const ahead = Math.round(0.005 * SOUND.sampleRate);
564
+ const release = 1 - Math.exp(-1 / (0.08 * SOUND.sampleRate));
499
565
  const q = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 32767) / 32767;
500
566
  const render = (gain) => {
567
+ // need[s]: the gain that keeps sample s under the ceiling; held[s]: the lowest need in the next
568
+ // `ahead` samples (a monotonic deque); then the release lets it rise slowly; then a moving average
569
+ // over `ahead` samples reaches every held value before its peak, so the result never exceeds need.
570
+ const need = new Float32Array(sampleCount);
571
+ for (let s = 0; s < sampleCount; s++) {
572
+ const a = Math.max(Math.abs(L[s]), Math.abs(R[s])) * gain;
573
+ need[s] = a > ceiling ? ceiling / a : 1;
574
+ }
575
+ const held = new Float32Array(sampleCount);
576
+ const deque = new Int32Array(sampleCount + ahead + 1);
577
+ let head = 0;
578
+ let tail = 0;
579
+ let next = 0;
580
+ for (let s = 0; s < sampleCount; s++) {
581
+ for (; next < Math.min(sampleCount, s + ahead + 1); next++) {
582
+ while (tail > head && need[deque[tail - 1]] >= need[next]) tail--;
583
+ deque[tail++] = next;
584
+ }
585
+ while (deque[head] < s) head++;
586
+ held[s] = need[deque[head]];
587
+ }
588
+ let env = 1;
589
+ const smooth = new Float32Array(sampleCount);
590
+ for (let s = 0; s < sampleCount; s++) {
591
+ env = Math.min(held[s], env + (1 - env) * release);
592
+ smooth[s] = env;
593
+ }
501
594
  const l = new Float32Array(sampleCount);
502
595
  const r = new Float32Array(sampleCount);
596
+ // The averaging window starts full of unity gain; the hard ceiling below catches the first 5 ms.
597
+ let win = ahead;
503
598
  for (let s = 0; s < sampleCount; s++) {
504
- l[s] = q(limit(L[s] * gain));
505
- r[s] = q(limit(R[s] * gain));
599
+ win += smooth[s] - (s >= ahead ? smooth[s - ahead] : 1);
600
+ const g = win / ahead;
601
+ l[s] = q(Math.max(-ceiling, Math.min(ceiling, L[s] * gain * g)));
602
+ r[s] = q(Math.max(-ceiling, Math.min(ceiling, R[s] * gain * g)));
506
603
  }
507
604
  return [l, r];
508
605
  };
@@ -515,7 +612,7 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
515
612
  [left, right] = render(gain);
516
613
  lufs = integratedLoudness([left, right]);
517
614
  }
518
- return { left, right, sampleCount, palette, cues, lufs: +lufs.toFixed(2), peakDbfs: +samplePeakDbfs([left, right]).toFixed(2), loudnessMethod: "BS.1770-4" };
615
+ return { left, right, gain, lufs };
519
616
  }
520
617
 
521
618
  export function writeBed({ treatment, frameCount, fps, cutTimes = [], dir }) {
@@ -621,6 +718,8 @@ export function auditMuxed({ ffmpeg, ffprobe = null, film, videoDurationSec, mod
621
718
  const peak = samplePeakDbfs(channels);
622
719
  const head = rmsRuns(channels);
623
720
  const lufs = integratedLoudness(channels);
721
+ // A generated bed and a score are built here to the loudness target; a supplied track is only reported.
722
+ const built = mode === "generated" || mode === "score";
624
723
  const rules = [{ id: "SOUND-STREAM", status: "PASS", detail: `audio stream present (${mode})` }];
625
724
  const dd = Math.abs(duration - videoDurationSec);
626
725
  rules.push({ id: "SOUND-DURATION", status: dd <= SOUND.durationToleranceSec ? "PASS" : "FAIL", detail: `audio ${duration.toFixed(3)} s vs video ${Number(videoDurationSec).toFixed(3)} s (limit ±${SOUND.durationToleranceSec} s)` });
@@ -628,14 +727,14 @@ export function auditMuxed({ ffmpeg, ffprobe = null, film, videoDurationSec, mod
628
727
  const headBad = head > SOUND.silenceMaxSec;
629
728
  rules.push({
630
729
  id: "SOUND-HEAD",
631
- status: headBad ? (mode === "generated" ? "FAIL" : "WARN") : "PASS",
730
+ status: headBad ? (built ? "FAIL" : "WARN") : "PASS",
632
731
  detail: `longest run below ${SOUND.silenceDbfs} dBFS RMS in the first ${SOUND.silenceHeadSec} s: ${head.toFixed(2)} s (limit ${SOUND.silenceMaxSec} s)`,
633
732
  });
634
733
  const inRange = Number.isFinite(lufs) && Math.abs(lufs - SOUND.targetLufs) <= SOUND.lufsTolerance;
635
734
  rules.push({
636
735
  id: "SOUND-LOUDNESS",
637
- status: mode === "generated" ? (inRange ? "PASS" : "WARN") : "PASS",
638
- detail: `integrated ${Number.isFinite(lufs) ? lufs.toFixed(2) : "-inf"} LUFS (BS.1770-4${mode === "generated" ? `, target ${SOUND.targetLufs} ± ${SOUND.lufsTolerance}` : ", reported"})`,
736
+ status: built ? (inRange ? "PASS" : "WARN") : "PASS",
737
+ detail: `integrated ${Number.isFinite(lufs) ? lufs.toFixed(2) : "-inf"} LUFS (BS.1770-4${built ? `, target ${SOUND.targetLufs} ± ${SOUND.lufsTolerance}` : ", reported"})`,
639
738
  });
640
- return { rules, exitCode: headBad && mode === "generated" ? 20 : null };
739
+ return { rules, exitCode: headBad && built ? 20 : null };
641
740
  }
@@ -9,6 +9,11 @@ import { fileURLToPath } from "node:url";
9
9
  import { EXIT } from "../core/constants.mjs";
10
10
  import { BlockedError } from "./chrome.mjs";
11
11
  import { checkFont, fontLocation, fontsDir, loadFontManifest } from "./runtime.mjs";
12
+ import { readPlate } from "./blender.mjs";
13
+
14
+ // The pinned three.js build and its few addons (engine/vendor/three, MIT), served at /lit/three/
15
+ // for the 3D route (references/depth.md). Pages import them through an import map; nothing is fetched.
16
+ export const THREE_DIR = fileURLToPath(new URL("../vendor/three/", import.meta.url));
12
17
 
13
18
  export const STAGE_ORIGIN = "http://lit.stage";
14
19
  export const KIT_FILE = fileURLToPath(new URL("../stage/stage-kit.js", import.meta.url));
@@ -179,17 +184,22 @@ const NETWORK_API = /\bnew\s+(WebSocket|WebTransport|RTCPeerConnection|EventSour
179
184
 
180
185
  /**
181
186
  * Scan the stage dir before launch. Throws exit 17 (contract) or 19 (network) naming the file and
182
- * the thing found; returns { files, rasters, warnings } otherwise.
187
+ * the thing found; returns { files, rasters, warnings, plate } otherwise (plate: the Blender plate, or null).
183
188
  */
184
189
  export function scanStage(stageDir) {
185
190
  if (!existsSync(path.join(stageDir, "index.html"))) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${path.join(stageDir, "index.html")} is missing; write the stage page first (references/stage.md)`);
186
191
  const files = listStage(stageDir);
187
192
  const warnings = [];
188
193
  const rasters = [];
194
+ // A Blender plate and its preview stills are frames the runner wrote and plate.json lists by name
195
+ // and size; they are not page rasters. Anything not listed is held to the raster limits.
196
+ const plate = readPlate(stageDir, "plate");
197
+ const preview = readPlate(stageDir, "plate-preview");
198
+ const listed = new Set([plate, preview].filter(Boolean).flatMap((p) => p.frames.map((f) => path.join(p.dir, f.name))));
189
199
  for (const f of files) {
190
200
  const ext = path.extname(f.rel).toLowerCase();
191
- if (!CONTENT_TYPES[ext]) warnings.push(`${f.rel}: not a servable type; the page cannot load it`);
192
- if (RASTER.includes(ext)) {
201
+ if (!CONTENT_TYPES[ext] && f.rel !== "scene.py") warnings.push(`${f.rel}: not a servable type; the page cannot load it`);
202
+ if (RASTER.includes(ext) && !listed.has(f.rel)) {
193
203
  const info = rasterInfo(f.real);
194
204
  if (!info) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${f.rel} is not a readable ${ext.slice(1)} image`);
195
205
  if (info.animated) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${f.rel} is an animated image; rasters are textures and stills, never a frame sequence`);
@@ -225,14 +235,14 @@ export function scanStage(stageDir) {
225
235
  for (const [size, names] of bySize) {
226
236
  if (names.length >= RASTER_LIMITS.flipbook) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${names.length} rasters of ${size} look like a flipbook; animate the page, not a frame sequence`);
227
237
  }
228
- return { files, rasters, warnings };
238
+ return { files, rasters, warnings, plate };
229
239
  }
230
240
 
231
241
  /**
232
242
  * The request router for one stage run. Returns { kind: "file", file, type } | { kind: "body", body,
233
243
  * type } | { kind: "blocked", reason } | { kind: "missing" }, and records every refusal.
234
244
  */
235
- export function stageRouter({ stageDir, faces }) {
245
+ export function stageRouter({ stageDir, faces, style = null }) {
236
246
  const root = realpathSync(stageDir);
237
247
  const css = fontsCss(faces);
238
248
  const byRoute = new Map(faces.map((f) => [f.route, f.file]));
@@ -251,9 +261,23 @@ export function stageRouter({ stageDir, faces }) {
251
261
  return { kind: "blocked" };
252
262
  }
253
263
  const pathname = decodeURIComponent(parsed.pathname);
264
+ // Chrome asks for a favicon on its own; the page did not, so it is not a missing file of the page.
265
+ if (pathname === "/favicon.ico") return { kind: "missing" };
254
266
  if (pathname === "/lit/stage-kit.js") return { kind: "file", file: KIT_FILE, type: CONTENT_TYPES[".js"] };
255
267
  if (pathname === "/lit/loop-kit.js") return { kind: "file", file: LOOP_KIT_FILE, type: CONTENT_TYPES[".js"] };
256
268
  if (pathname === "/lit/fonts.css") return { kind: "body", body: css, type: CONTENT_TYPES[".css"] };
269
+ if (pathname.startsWith("/lit/three/")) {
270
+ const rel = pathname.slice("/lit/three/".length);
271
+ const file = path.join(THREE_DIR, rel);
272
+ if (rel.split("/").includes("..") || !rel.endsWith(".js") || !existsSync(file)) {
273
+ missing.push({ url, reason: "not a vendored three.js file" });
274
+ return { kind: "missing" };
275
+ }
276
+ return { kind: "file", file, type: CONTENT_TYPES[".js"] };
277
+ }
278
+ // The film's house style (references/studio.md): type, colour and zone tokens, and the shot timeline.
279
+ if (style && pathname === "/lit/style.css") return { kind: "body", body: style.css, type: CONTENT_TYPES[".css"] };
280
+ if (style && pathname === "/lit/style.js") return { kind: "body", body: style.js, type: CONTENT_TYPES[".js"] };
257
281
  if (byRoute.has(pathname)) return { kind: "file", file: byRoute.get(pathname), type: CONTENT_TYPES[path.extname(pathname)] };
258
282
  const rel = pathname === "/" ? "index.html" : pathname.replace(/^\/+/u, "");
259
283
  if (rel.split("/").includes("..")) {
@@ -11,6 +11,9 @@ import path from "node:path";
11
11
  import { EXIT, GATE, PREVIEW } from "../core/constants.mjs";
12
12
  import { FlashDetector, cellsFromRgba, detectSequence, gridFor } from "../core/flash.mjs";
13
13
  import { areaDownscale } from "./analysis.mjs";
14
+ import { assetUsed } from "./assets.mjs";
15
+ import { BLENDER } from "./blender.mjs";
16
+ import { addToStills, writeBoard } from "./board.mjs";
14
17
  import { BlockedError } from "./chrome.mjs";
15
18
  import { findFfmpeg, findFfprobe, findImg2webp, previewRungs, startMaster, encodePreview, probeVideo } from "./encode.mjs";
16
19
  import { resolveViewed, asBlocked } from "./pipeline.mjs";
@@ -21,6 +24,8 @@ import { StageBrowser, FramePool, analyzeChecked } from "./stage-capture.mjs";
21
24
  import { evaluateStageGate, formatStageReport } from "./stage-gate.mjs";
22
25
  import { textQaReplay } from "./stage-qa.mjs";
23
26
  import { treatmentSha } from "./done.mjs";
27
+ import { recordHouse } from "./house.mjs";
28
+ import { styleCss, styleScript, readStyle } from "./styles.mjs";
24
29
  import { scanStage, stageFonts } from "./stage-serve.mjs";
25
30
  import { beatCuts, stillsPlan, writeStills } from "./stills.mjs";
26
31
  import { FORMATS, loadTreatment } from "./treatment.mjs";
@@ -33,11 +38,19 @@ const writeJson = (file, value) => {
33
38
  writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
34
39
  };
35
40
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
41
+ /** The house style the page is served (/lit/style.css, /lit/style.js) for this treatment and format. */
42
+ export const styleAssets = (treatment) => {
43
+ const style = readStyle(treatment.style);
44
+ return { style, css: styleCss(style, treatment.format), js: styleScript(style, treatment.format, treatment) };
45
+ };
36
46
  const percentile = (values, p) => {
37
47
  const sorted = [...values].sort((a, b) => a - b);
38
48
  return sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))].toFixed(1) : null;
39
49
  };
40
50
 
51
+ /** The beat stills of a stills set, as absolute paths, in beat order. */
52
+ export const beatStillFiles = (out, stills) => stills.index.files.filter((f) => f.kind === "beat").sort((a, b) => a.beat - b.beat).map((f) => path.join(out, f.file));
53
+
41
54
  /** Determinism samples: frame 0, the last frame, each beat's first frame, beat midpoints to 8, at most 16. */
42
55
  export function determinismFrames({ beats, fps, frameCount }) {
43
56
  const clamp = (f) => Math.max(0, Math.min(frameCount - 1, f));
@@ -83,10 +96,30 @@ function violationExit(state, router) {
83
96
  return null;
84
97
  }
85
98
 
86
- /** Open a stage browser and check the page's LitStage contract against the treatment. */
99
+ /**
100
+ * Open a stage browser on the round's rung and check the page's LitStage contract. A 3D film asks for
101
+ * the GPU rung; when that rung cannot launch or gives the page no WebGL, the round falls back to the
102
+ * software rung for every browser (master and replays must share one rung) and records a notice.
103
+ */
87
104
  async function openChecked({ label, ctx }) {
105
+ if (ctx.rung === "gpu") {
106
+ try {
107
+ const browser = await openOnRung({ label, ctx });
108
+ if (!browser.state.webglMissing) return browser;
109
+ await browser.close();
110
+ ctx.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");
111
+ } catch (error) {
112
+ if (error instanceof BlockedError) throw error;
113
+ ctx.notices.push(`the 3D route asked for the GPU rung and it did not start (${String(error.message).slice(0, 120)}); rendered on the software rung (SwiftShader), slower, same frames`);
114
+ }
115
+ ctx.rung = "software";
116
+ }
117
+ return openOnRung({ label, ctx });
118
+ }
119
+
120
+ async function openOnRung({ label, ctx }) {
88
121
  const { stageDir, faces, scan, width, height, treatment, runDir, env, log } = ctx;
89
- const browser = await StageBrowser.open({ stageDir, faces, rasters: scan.rasters, width, height, seed: treatment.seed, profileDir: path.join(runDir, `c${process.pid % 100000}${label}`), env, log });
122
+ const browser = await StageBrowser.open({ stageDir, faces, style: ctx.style, rasters: scan.rasters, width, height, seed: treatment.seed, profileDir: path.join(runDir, `c${process.pid % 100000}${label}`), rung: ctx.rung, env, log });
90
123
  const state = browser.state;
91
124
  const fail = (message) => new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${message}`);
92
125
  const early = violationExit(state, browser.router);
@@ -97,9 +130,9 @@ async function openChecked({ label, ctx }) {
97
130
  else if (!error && spec.render !== "function") error = fail("LitStage.define needs render(t)");
98
131
  else if (!error && spec.fps !== (treatment.fps ?? 60)) error = fail(`the page runs at ${spec.fps} fps; the treatment asks for ${treatment.fps ?? 60} (30 only when the treatment says fps: 30)`);
99
132
  else if (!error && !(spec.duration > 0)) error = fail("the page's duration must be a positive number of seconds");
100
- if (!error && state.webglMissing) error = new BlockedError(EXIT.BLOCKED_NO_WEBGL2, "BLOCKED_NO_WEBGL2: the page asked for a WebGL context and the software rung gave none");
133
+ if (!error && state.webglMissing) error = new BlockedError(EXIT.BLOCKED_NO_WEBGL2, `BLOCKED_NO_WEBGL2: the page asked for a WebGL context and the ${ctx.rung} rung gave none`);
101
134
  if (!error && browser.prepared?.failedFaces?.length) log(`warning: faces that did not load: ${browser.prepared.failedFaces.join(", ")}`);
102
- if (error) {
135
+ if (error && !(ctx.rung === "gpu" && error.code === EXIT.BLOCKED_NO_WEBGL2)) {
103
136
  await browser.close();
104
137
  throw error;
105
138
  }
@@ -141,6 +174,18 @@ async function captureFrames({ browser, pool, fps, frames, width, height, asMast
141
174
  return out;
142
175
  }
143
176
 
177
+ /**
178
+ * The browser rung: a three.js world asks for the GPU; a Blender film shows its path-traced plate
179
+ * under HTML type, so its page needs only the software rung, and it needs the plate, all of it.
180
+ */
181
+ export function stageRung(treatment, scan, out) {
182
+ if (treatment.render3d !== "blender") return treatment.depth === "3d" ? "gpu" : "software";
183
+ const want = Math.round(treatment.durationSec * BLENDER.plateFps);
184
+ if (!scan.plate) throw new BlockedError(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 ${out} --final (no Blender: set render3d to three and use the GPU rung)`);
185
+ if (scan.plate.frames.length !== want) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: the plate has ${scan.plate.frames.length} frames; a ${treatment.durationSec} s film needs ${want} at ${BLENDER.plateFps} fps (render it again: blender --out ${out} --final)`);
186
+ return "software";
187
+ }
188
+
144
189
  function renderId(treatment, scan) {
145
190
  const h = createHash("sha256").update(JSON.stringify(treatment));
146
191
  for (const f of scan.files) h.update(f.rel).update(readFileSync(f.real));
@@ -213,7 +258,7 @@ export async function captureStageFrames({ out, frames, env = process.env, log =
213
258
  const faces = stageFonts(env);
214
259
  const runDir = path.join(out, ".run");
215
260
  mkdirSync(runDir, { recursive: true });
216
- const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log };
261
+ const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log, style: styleAssets(treatment), rung: stageRung(treatment, scan, out), notices: [] };
217
262
  const browser = await openChecked({ label: "x", ctx });
218
263
  const pool = new FramePool(2);
219
264
  try {
@@ -285,7 +330,7 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
285
330
  const runDir = path.join(out, ".run");
286
331
  mkdirSync(runDir, { recursive: true });
287
332
  const ffmpeg = findFfmpeg(env);
288
- const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log };
333
+ const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log, style: styleAssets(treatment), rung: stageRung(treatment, scan, out), notices: [] };
289
334
  progress(out, { phase: "open", round, stillsOnly });
290
335
  const master = await openChecked({ label: "m", ctx });
291
336
  const spec = master.state.spec;
@@ -300,11 +345,30 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
300
345
  if (stillsOnly) {
301
346
  const frames = await captureFrames({ browser: master, pool, fps, frames: plan.all, width, height });
302
347
  const stills = writeStills({ out, plan, frames: new Map([...frames].map(([f, a]) => [f, a.rgba])), width, height, fps, frameCount, round, stillsOnly: true, path: "stage" });
348
+ // The board round: the text and style rules run on the board frames before any film is encoded,
349
+ // and the storyboard is drawn with the verdict on it.
350
+ progress(out, { phase: "board-check", round, stillsOnly });
351
+ const replayQa = qa ?? textQaReplay({ pool, stageFiles: scan.files.map((f) => path.basename(f.rel)) });
352
+ const check = await replayQa({ ctx, fps, frameCount, plan, openBrowser: (label) => openChecked({ label, ctx }), stepChecked });
353
+ const assetRule = assetUsed(treatment, scan.files);
354
+ if (assetRule) check.rules.push(assetRule);
355
+ writeJson(path.join(out, "stills", "check.json"), { rules: check.rules });
356
+ const boardFile = await writeBoard({ out, treatment, beatFiles: beatStillFiles(out, stills), check, env, log });
357
+ stills.sha256 = addToStills(out, { file: "stills/board.png", kind: "board", frames: plan.beats }, { sha256 });
303
358
  progress(out, { phase: "done", round, stillsOnly });
359
+ const failed = check.rules.filter((x) => x.status === "FAIL");
360
+ if (check.exit) return { code: check.exit, message: `${check.message} (board check; the storyboard is ${boardFile})`, stills, renderId: id, check };
361
+ if (failed.length) {
362
+ return {
363
+ code: EXIT.GATE_FAIL_QA,
364
+ message: `BOARD_CHECK_FAIL: ${failed.map((x) => `${x.id} ${x.detail}`).join("; ")}. Fix the page, render --stills-only again, then look at the board ${boardFile} and every file in ${path.join(out, "stills")}`,
365
+ stills, renderId: id, check,
366
+ };
367
+ }
304
368
  return {
305
369
  code: EXIT.OK,
306
- message: `STILLS_ONLY: look at every file in ${path.join(out, "stills")} (index.json lists them), record the round with look --round ${round}, make the change, then render the film with --round ${round + 1} (no --stills-only)`,
307
- stills, renderId: id,
370
+ message: `STILLS_ONLY: board check PASS; open the storyboard ${boardFile} and every file in ${path.join(out, "stills")} (index.json lists them), record the round with look --round ${round}, make the change, then render the film with --round ${round + 1} (no --stills-only)`,
371
+ stills, renderId: id, check,
308
372
  };
309
373
  }
310
374
  if (!ffmpeg) {
@@ -350,7 +414,7 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
350
414
  const a = await entry.analysis;
351
415
  await encoder.write(a.rgba);
352
416
  const flash = detector.push(a.cells);
353
- lines.push({ frame: entry.f, artifact: "master", rgbaSha256: a.rgbaSha256, luminanceP995: a.luminanceP995, flash });
417
+ lines.push({ frame: entry.f, artifact: "master", rgbaSha256: a.rgbaSha256, luminanceP995: a.luminanceP995, luminanceP005: a.luminanceP005, busy: a.busy, flash });
354
418
  if (plan.all.has(entry.f)) kept.set(entry.f, a.rgba);
355
419
  if (detFrames.includes(entry.f)) detMaster.set(entry.f, a);
356
420
  if (entry.f % previewEvery === 0) {
@@ -401,23 +465,27 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
401
465
  };
402
466
  const gate = evaluateStageGate({
403
467
  masterFlash: lines.map((l) => l.flash), previewFlash: preview.records ? { records: preview.records, fps: preview.rung.fps } : null, fps,
404
- probe, width, height, treatment, exports, reducedFrame: plan.reduced, frames: lines, usesWebGL: finalState.usesWebGL, renderer: master.renderer,
405
- determinism, extraRules: [...soundAudit.rules, ...(qaResult?.rules ?? [])],
468
+ probe, width, height, treatment, exports, reducedFrame: plan.reduced, frames: lines, usesWebGL: finalState.usesWebGL, renderer: finalState.webglRenderer, rung: ctx.rung,
469
+ determinism, extraRules: [...soundAudit.rules, ...(qaResult?.rules ?? []), assetUsed(treatment, scan.files)].filter(Boolean),
406
470
  });
407
471
  const promoted = promote(out, gate.flashFail);
408
472
  const loc = (name) => path.join(promoted.dir, name);
409
473
  const stills = writeStills({ out, plan, frames: kept, width, height, fps, frameCount, round, stillsOnly: false, path: "stage", posterFile: existsSync(loc("poster.png")) ? loc("poster.png") : null });
474
+ await writeBoard({ out, treatment, beatFiles: beatStillFiles(out, stills), check: qaResult, env, log });
475
+ stills.sha256 = addToStills(out, { file: "stills/board.png", kind: "board", frames: plan.beats }, { sha256 });
476
+ if (gate.pass && !promoted.withheld) recordHouse(out, { style: treatment.style, path: "stage", format: treatment.format, durationSec: treatment.durationSec });
410
477
  const totalSec = (Date.now() - t0) / 1000;
411
478
  const manifest = {
412
479
  schemaVersion: 1, path: "stage", renderId: id, round, treatmentSha256: treatmentSha(out), format: treatment.format, width, height, fps, durationSec: spec.duration, treatmentDurationSec: treatment.durationSec,
413
- frameCount, seed: treatment.seed, renderer: "SwiftShader (software rung)", chromeFlags: master.flags, usesWebGL: finalState.usesWebGL,
480
+ frameCount, seed: treatment.seed, renderer: finalState.webglRenderer ?? "no WebGL", rung: ctx.rung, chromeFlags: master.flags, usesWebGL: finalState.usesWebGL,
414
481
  cuts, stills: { sha256: stills.sha256, poster: plan.poster, reduced: plan.reduced }, posterEncoding: poster,
415
482
  geometry: geometryFor(width, height),
483
+ plate: scan.plate ? { blender: scan.plate.blender, engine: scan.plate.engine, device: scan.plate.device, samples: scan.plate.samples, fps: scan.plate.fps, frames: scan.plate.frames.length } : null,
416
484
  sound: { mode: sound.mode, label: sound.label, file: sound.file ? path.relative(out, sound.file) : null, sha256: sound.file ? sha256(readFileSync(sound.file)) : null, lufs: sound.bed?.lufs ?? null },
417
485
  preview: { encoder: preview.encoder, rung: preview.rung, attempts: preview.attempts },
418
486
  pageErrors: finalState.errors, missingAssets: master.router.missing,
419
487
  timing: { frameP50Ms: percentile(frameMs, 0.5), frameP95Ms: percentile(frameMs, 0.95), masterSec: +masterSec.toFixed(1), totalSec: +totalSec.toFixed(1) },
420
- warnings: [...scan.warnings, ...master.router.missing.map((m) => `the page asked for a missing file ${m.url} (${m.reason})`)],
488
+ warnings: [...ctx.notices, ...scan.warnings, ...master.router.missing.map((m) => `the page asked for a missing file ${m.url} (${m.reason})`)],
421
489
  generatedAt: new Date().toISOString(),
422
490
  };
423
491
  writeJson(path.join(out, "manifest.json"), manifest);
@@ -492,7 +560,7 @@ export async function renderStageDetached({ out, round, cli, mode = "stage", env
492
560
  }
493
561
  try {
494
562
  const p = JSON.parse(readFileSync(path.join(runDir, "progress.json"), "utf8"));
495
- const phase = `${p.phase}${p.frame !== undefined && p.phase === "master" ? ` ${Math.floor((p.frame / p.frameCount) * 10) * 10}%` : ""}`;
563
+ const phase = `${p.phase}${p.frame !== undefined && (p.phase === "master" || p.phase === "plate") ? ` ${Math.floor((p.frame / p.frameCount) * 10) * 10}%` : ""}`;
496
564
  if (phase !== lastPhase) log(`render: ${phase}`);
497
565
  lastPhase = phase;
498
566
  } catch {