@litfamily/lithermes 1.0.28 → 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 (167) 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 +472 -60
  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/readme-studio/templates/hyperframes/package-lock.json +3 -3
  161. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  162. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  163. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  164. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  165. package/package.json +1 -1
  166. package/readme-assets/badge-version.svg +1 -1
  167. package/src/lib/skillPayload.js +1 -1
@@ -0,0 +1,154 @@
1
+ // The offline Blender rung (references/depth.md): the runner (engine/blender/runner.py, in
2
+ // Blender's own Python, no venv) renders the film's scene.py with Cycles on the GPU when it has
3
+ // one, else the CPU, and writes receipt.json. Call it `motion.mjs blender --out <dir>` for one
4
+ // still at every beat midpoint, `--final` for the plate sequence: preview first, compare with any
5
+ // reference frames the user gave and with what the GPU rung built, then render the final plate.
6
+ // The stage shows the plate under its type and treats a 3-D-of-any-kind page the same with or
7
+ // without one. The run is sequenced (not parallel), and the same scene.py, receipt and frames
8
+ // render exactly the same plate again: same seed, same Blender, same bytes.
9
+ import { execSync, spawn } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { delimiter, dirname, join, basename, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { EXIT } from './constants.mjs';
15
+ import { FORMATS, loadTreatment } from './treatment.mjs';
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const fail = (code, message, extra = {}) => Object.assign(new Error(message), { exitCode: code, ...extra });
19
+
20
+ export const BLENDER = Object.freeze({
21
+ // Preview: fast enough to compare against the GPU rung (CPU 32 spp renders a 100 % frame in
22
+ // seconds). Final: within 2 dB of a 1024-spp reference, seams included. Quality bands calibrated
23
+ // 2026-10-24 on this machine (Apple M3 Max): preview landed at 1.1 s median d, stills look right.
24
+ preview: Object.freeze({ percent: 50, samples: 32 }),
25
+ final: Object.freeze({ percent: 100, samples: 128 }),
26
+ runner: join(here, 'blender', 'runner.py'),
27
+ macApp: '/Applications/Blender.app/Contents/MacOS/Blender',
28
+ device: 'METAL',
29
+ plateFps: 30,
30
+ });
31
+
32
+ // Where Blender lives: a LITHERMES_BLENDER override first (pinned and never second-guessed), then
33
+ // the macOS app, then `blender` on PATH. Returns { path, source } or null.
34
+ export function findBlender({ env = process.env, exists = existsSync, platform = process.platform } = {}) {
35
+ if (env.LITHERMES_BLENDER) return exists(env.LITHERMES_BLENDER) ? { path: env.LITHERMES_BLENDER, source: 'LITHERMES_BLENDER' } : null;
36
+ if (platform === 'darwin' && exists(BLENDER.macApp)) return { path: BLENDER.macApp, source: 'app' };
37
+ for (const dir of String(env.PATH ?? '').split(delimiter).filter(Boolean)) {
38
+ const file = join(dir, 'blender');
39
+ if (exists(file)) return { path: file, source: 'PATH' };
40
+ }
41
+ return null;
42
+ }
43
+
44
+ const plateFrames = (treatment) => Math.round(treatment.durationSec * BLENDER.plateFps);
45
+
46
+ export function blenderArgs({ treatment, scene, out, quality }) {
47
+ const q = BLENDER[quality];
48
+ if (!q) throw new Error(`blenderArgs: quality is preview or final (got ${quality})`);
49
+ const [width, height] = FORMATS[treatment.format];
50
+ const seed = Number.isInteger(treatment.seed)
51
+ ? treatment.seed >>> 0
52
+ : parseInt(createHash('sha256').update(String(treatment.request)).digest('hex').slice(0, 8), 16) >>> 0;
53
+ const args = ['-b', '--factory-startup', '-P', BLENDER.runner, '--',
54
+ '--scene', scene, '--out', out, '--width', String(width), '--height', String(height),
55
+ '--percent', String(q.percent), '--samples', String(q.samples), '--device', BLENDER.device,
56
+ '--fps', String(BLENDER.plateFps), '--seed', String(seed % 2147483648)];
57
+ if (quality === 'preview') args.push('--stills', treatment.beats.map((b) => +((b.t0 + b.t1) / 2).toFixed(3)).join(','));
58
+ else args.push('--frames', `1-${plateFrames(treatment)}`);
59
+ return args;
60
+ }
61
+
62
+ const FALLBACK = (out) => [
63
+ 'FALLBACK: no Blender found (LITHERMES_BLENDER, /Applications/Blender.app, blender on PATH), so nothing was rendered.',
64
+ `Set render3d to "three" in ${join(out, 'treatment.json')} and build the world in three.js on the GPU rung (references/depth.md),`,
65
+ 'and say in the reply that the film used the GPU rung because Blender is not installed.',
66
+ ].join('\n');
67
+
68
+ const listPngs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.png')).sort() : []);
69
+
70
+ function runBlender({ bin, args, dir, expected, progress }) {
71
+ return new Promise((resolve) => {
72
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
73
+ let tail = '';
74
+ const keep = (chunk) => {
75
+ tail = (tail + chunk).slice(-4000);
76
+ };
77
+ child.stdout.on('data', keep);
78
+ child.stderr.on('data', keep);
79
+ const timer = progress ? setInterval(() => progress(listPngs(dir).length, expected), 2000) : null;
80
+ child.on('close', (code) => {
81
+ if (timer) clearInterval(timer);
82
+ resolve({ code, tail });
83
+ });
84
+ child.on('error', (error) => {
85
+ if (timer) clearInterval(timer);
86
+ resolve({ code: -1, tail: error.message });
87
+ });
88
+ });
89
+ }
90
+
91
+ // `motion.mjs blender --out <dir> [--final]`. Preview: stills in stage/plate-preview/. Final: the
92
+ // plate sequence in stage/plate/ plus plate.json naming every frame with its size, carrying the
93
+ // device, samples and Blender version from the runner's receipt.
94
+ export async function blenderCommand({ out, final = false, env = process.env, log = () => {}, progress = null }) {
95
+ const { treatment } = loadTreatment(out, EXIT.BLOCKED_TREATMENT_INVALID);
96
+ if (treatment.path !== 'stage') throw fail(EXIT.BLOCKED_TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID: field path: blender renders the stage path (this treatment takes the ${treatment.path} path)`);
97
+ if (treatment.render3d !== 'blender') throw fail(2, `blender renders a treatment with render3d "blender" (this one is ${treatment.render3d ?? 'three'})`);
98
+ const stageDir = join(out, 'stage');
99
+ const scene = join(stageDir, 'scene.py');
100
+ if (!existsSync(scene)) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${scene} is missing; write the Blender scene first (references/depth.md, the Blender rung)`);
101
+ const found = findBlender({ env });
102
+ if (!found) return { code: EXIT.OK, message: FALLBACK(out) };
103
+ const quality = final ? 'final' : 'preview';
104
+ const dir = join(stageDir, final ? 'plate' : 'plate-preview');
105
+ rmSync(dir, { recursive: true, force: true });
106
+ mkdirSync(dir, { recursive: true });
107
+ const args = blenderArgs({ treatment, scene, out: dir, quality });
108
+ const expected = final ? plateFrames(treatment) : treatment.beats.length;
109
+ log(`blender ${quality}: ${found.path} (${found.source}), ${expected} ${final ? 'frames' : 'stills'}`);
110
+ const run = await runBlender({ bin: found.path, args, dir, expected, progress });
111
+ const receiptFile = join(dir, 'receipt.json');
112
+ if (run.code !== 0 || !existsSync(receiptFile)) {
113
+ throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: Blender ${quality} failed (exit ${run.code}); fix stage/scene.py and run it again. Last output: ${run.tail.trim().split('\n').slice(-8).join(' | ')}`);
114
+ }
115
+ const receipt = JSON.parse(readFileSync(receiptFile, 'utf8'));
116
+ const frames = receipt.files.map((name) => ({ name, bytes: statSync(join(dir, name)).size }));
117
+ if (frames.length !== expected) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: Blender wrote ${frames.length} of ${expected} ${final ? 'frames' : 'stills'}`);
118
+ const plate = { kind: quality, blender: receipt.blender, engine: receipt.engine, device: receipt.device, samples: receipt.samples, percent: receipt.percent, fps: BLENDER.plateFps, frames };
119
+ writeFileSync(join(dir, 'plate.json'), `${JSON.stringify(plate, null, 1)}\n`);
120
+ const label = `Blender ${receipt.blender}, ${receipt.engine} ${receipt.device}, ${receipt.samples} spp at ${receipt.percent} %`;
121
+ if (!final) {
122
+ return { code: EXIT.OK, message: `BLENDER preview: ${frames.length} stills at the beat midpoints in ${dir} (${label}). Open them, compare them with any reference frames the user gave, fix scene.py, then render the plate: blender --out ${out} --final` };
123
+ }
124
+ return { code: EXIT.OK, message: `BLENDER plate: ${frames.length} frames at ${BLENDER.plateFps} fps in ${dir} (${label}). Show it with LitStage.plate() under the type, then render the stage: stage --out ${out} --round N` };
125
+ }
126
+
127
+ // The plate a stage dir carries: { dir, frames, device, ... } when plate.json lists frames all present as listed, else null.
128
+ export function readPlate(stageDir, sub = 'plate') {
129
+ const file = join(stageDir, sub, 'plate.json');
130
+ if (!existsSync(file)) return null;
131
+ let plate;
132
+ try {
133
+ plate = JSON.parse(readFileSync(file, 'utf8'));
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!Array.isArray(plate.frames)) return null;
138
+ const ok = plate.frames.every((f) => {
139
+ const p = join(stageDir, sub, String(f.name));
140
+ return basename(String(f.name)) === f.name && existsSync(p) && statSync(p).size === f.bytes;
141
+ });
142
+ return ok ? { ...plate, dir: sub } : null;
143
+ }
144
+
145
+ // Blender's own version for the plate receipt (null when Blender is not installed here).
146
+ export function blenderVersion({ env = process.env } = {}) {
147
+ const found = findBlender({ env });
148
+ if (!found) return null;
149
+ try {
150
+ return execSync(`"${found.path}" --version`, { encoding: 'utf8' }).split('\n')[0].trim() || null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
@@ -0,0 +1,132 @@
1
+ // The storyboard: `stills/board.png`, one page a person reads before the full render. One
2
+ // keyframe per shot (the beat still) with number, time, layout and hand-off under it and its
3
+ // hero move in words, the shot's type zone outlined on the frame; a header with the house style
4
+ // (name, palette and type) plus format, length and sound; a footer with the layout check. It is
5
+ // drawn as a page in the same headless Chrome the stage uses, so its type is the product's faces.
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { LAYOUTS, readStyle } from './styles.mjs';
9
+ import { ORIGIN, openStage } from './stage.mjs';
10
+
11
+ export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
12
+
13
+ const esc = (text) => String(text ?? '').replace(/[&<>"]/gu, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[char]);
14
+ const secs = (value) => `${Number(value).toFixed(1)}`;
15
+
16
+ /** Tile geometry: columns and thumbnail size for n shots of a width x height film. */
17
+ export function boardGrid(n, width, height) {
18
+ const usableW = BOARD.width - 2 * BOARD.margin;
19
+ const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
20
+ const aspect = width / height;
21
+ const options = [];
22
+ for (let cols = 1; cols <= Math.max(1, n); cols++) {
23
+ const rows = Math.ceil(n / cols);
24
+ const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
25
+ const th = Math.min(Math.round(tw / aspect), Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption);
26
+ if (th <= 0) continue;
27
+ options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
28
+ }
29
+ // The largest tiles win, unless a grid with fewer empty slots keeps 85 % of their area.
30
+ const largest = Math.max(...options.map((option) => option.tw * option.th));
31
+ return options.filter((option) => option.tw * option.th >= 0.85 * largest).sort((a, b) => a.empty - b.empty || b.tw * b.th - a.tw * a.th)[0];
32
+ }
33
+
34
+ /** The board page HTML for a treatment and its beat stills (names relative to the page). */
35
+ export function boardHtml({ treatment, stills, check = null }) {
36
+ const style = treatment.style ? readStyle(treatment.style) : null;
37
+ const [width, height] = treatment.format === '9:16' ? [1080, 1920] : [1920, 1080];
38
+ const grid = boardGrid(stills.length, width, height);
39
+ const zones = LAYOUTS[treatment.format === '9:16' ? '9:16' : '16:9'];
40
+ const palette = style?.palette ?? {};
41
+ const chips = Object.entries(palette).filter(([key]) => ['ground', 'ink', 'muted', 'signal', 'accent'].includes(key))
42
+ .map(([key, value]) => `<span class="chip"><i style="background:${esc(value)}"></i>${esc(key)} ${esc(value)}</span>`).join('');
43
+ const tiles = stills.map((file, index) => {
44
+ const beat = treatment.beats[index] ?? {};
45
+ const zone = beat.layout && zones[beat.layout]?.type;
46
+ const guide = zone ? `<b class="zone" style="left:${(zone[0] * 100).toFixed(2)}%;top:${(zone[1] * 100).toFixed(2)}%;width:${(zone[2] * 100).toFixed(2)}%;height:${(zone[3] * 100).toFixed(2)}%"></b>` : '';
47
+ const meta = [String(index + 1).padStart(2, '0'), `${secs(beat.t0)}–${secs(beat.t1)} s`, beat.layout, beat.transition && beat.transition !== 'end' ? `→ ${beat.transition}` : beat.transition].filter(Boolean).map(esc).join(' · ');
48
+ return `<figure style="width:${grid.tw}px"><div class="frame" style="width:${grid.tw}px;height:${grid.th}px"><img src="${esc(file)}">${guide}</div>`
49
+ + `<figcaption><span class="meta">${meta}</span><span class="hero">${esc(beat.hero ?? beat.onScreen ?? '')}</span></figcaption></figure>`;
50
+ }).join('');
51
+ const failed = (check?.rules ?? []).filter((rule) => rule.status === 'FAIL');
52
+ const verdict = !check ? 'layout check: not run on this render'
53
+ : failed.length ? `layout check: FAIL — ${failed.map((rule) => rule.id).join(', ')}` : 'layout check: PASS';
54
+ const sound = treatment.sound?.mode === 'score' && treatment.style
55
+ ? `score (${readStyle(treatment.style).score.genre})` : treatment.sound?.mode ?? 'none';
56
+ const ground = palette.ground ?? '#101216';
57
+ return `<!doctype html>
58
+ <meta charset="utf-8">
59
+ <link rel="stylesheet" href="/lit/fonts.css">
60
+ <style>
61
+ html, body { margin: 0; width: ${BOARD.width}px; height: ${BOARD.height}px; overflow: hidden; background: #15171b; color: #e8e9e6; font-family: "Pretendard"; }
62
+ header { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: 36px; height: ${BOARD.header - 48}px; display: flex; justify-content: space-between; align-items: flex-start; }
63
+ h1 { margin: 0; font: 700 40px/1.1 "Pretendard"; }
64
+ .sub { margin-top: 10px; font: 400 20px/1.3 "MesloLGS NF"; color: #a8aba6; }
65
+ .style { text-align: right; }
66
+ .specimen { display: inline-block; padding: 6px 14px; margin-bottom: 10px; background: ${esc(ground)}; color: ${esc(palette.ink ?? '#fff')}; font: ${style?.faces.display.weight ?? 700} 34px/1.1 "${esc(style?.faces.display.family ?? 'Pretendard')}", "Pretendard"; }
67
+ .chips { display: flex; gap: 14px; justify-content: flex-end; font: 400 16px/1 "MesloLGS NF"; color: #a8aba6; }
68
+ .chip i { display: inline-block; width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; border: 1px solid #555; }
69
+ main { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: ${BOARD.header}px; display: flex; flex-wrap: wrap; gap: ${BOARD.gap}px; }
70
+ figure { margin: 0; }
71
+ .frame { position: relative; overflow: hidden; background: #000; }
72
+ .frame img { display: block; width: 100%; height: 100%; }
73
+ .zone { position: absolute; box-sizing: border-box; border: 2px dashed rgba(255, 196, 0, 0.75); }
74
+ figcaption { display: block; height: ${BOARD.caption - 8}px; padding-top: 8px; overflow: hidden; }
75
+ .meta { display: block; font: 400 16px/1.3 "MesloLGS NF"; color: #c9cbc6; }
76
+ .hero { display: block; margin-top: 4px; font: 400 18px/1.35 "Pretendard"; color: #e8e9e6; }
77
+ footer { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; bottom: 18px; font: 400 18px/1.2 "MesloLGS NF"; color: ${failed.length ? '#ff8a7a' : '#9fd3a4'}; }
78
+ </style>
79
+ <body>
80
+ <header>
81
+ <div><h1>Storyboard · ${esc(treatment.subject?.name ?? '')}</h1>
82
+ <div class="sub">${esc(treatment.format)} · ${esc(treatment.durationSec)} s · ${treatment.beats.length} shots · ${esc(treatment.path)} path · sound: ${esc(sound)}</div></div>
83
+ <div class="style"><div class="specimen">${esc(style?.name ?? treatment.style ?? '')} Aa 가</div><div class="chips">${chips}</div></div>
84
+ </header>
85
+ <main>${tiles}</main>
86
+ <footer>${esc(verdict)} · dashed box: the shot's type zone</footer>
87
+ <script>window.__litBoard = true;</script>
88
+ </body>
89
+ `;
90
+ }
91
+
92
+ /**
93
+ * Render `<out>/stills/board.png` from the beat stills already written. Returns the file path.
94
+ * `open({ stageDir, width, height })` opens the stage renderer on a directory; `check` is the
95
+ * layout check ({ rules }) drawn with its verdict onto the board.
96
+ */
97
+ export async function writeBoard({ out, treatment, beatFiles, check = null, open }) {
98
+ const dir = path.join(out, '.run', 'board');
99
+ rmSync(dir, { recursive: true, force: true });
100
+ mkdirSync(dir, { recursive: true });
101
+ const names = beatFiles.map((file, index) => {
102
+ const name = `s${String(index + 1).padStart(2, '0')}.png`;
103
+ copyFileSync(file, path.join(dir, name));
104
+ return name;
105
+ });
106
+ writeFileSync(path.join(dir, 'index.html'), boardHtml({ treatment, stills: names, check }));
107
+ const browser = await open({ stageDir: dir, width: BOARD.width, height: BOARD.height });
108
+ try {
109
+ await browser.page.goto(`${ORIGIN}/index.html`, { waitUntil: 'load', timeout: 120000 });
110
+ await browser.page.waitForTimeout(900);
111
+ const png = await browser.capture();
112
+ const file = path.join(out, 'stills', 'board.png');
113
+ writeFileSync(file, png);
114
+ return file;
115
+ } finally {
116
+ await browser.close();
117
+ rmSync(dir, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ /** Add a file the renderer wrote after the stills set to `stills/stills.json`; returns the new set SHA-256. */
122
+ export function addToStills(out, entry, { sha256 }) {
123
+ const indexFile = path.join(out, 'stills', 'stills.json');
124
+ const index = JSON.parse(readFileSync(indexFile, 'utf8'));
125
+ const file = path.join(out, entry.file);
126
+ if (!existsSync(file)) throw new Error(`addToStills: ${entry.file} was not written`);
127
+ const bytes = readFileSync(file);
128
+ index.files = [...index.files.filter((f) => f.file !== entry.file), { ...entry, sha256: sha256(bytes) }];
129
+ const out2 = Buffer.from(`${JSON.stringify(index, null, 2)}\n`);
130
+ writeFileSync(indexFile, out2);
131
+ return sha256(out2);
132
+ }
@@ -10,14 +10,57 @@ import { join } from 'node:path';
10
10
 
11
11
  const ARTIFACTS = ['film.mp4', 'poster.png', 'reduced-motion.png'];
12
12
  const previewName = (dir) => ['preview.webp', 'preview.gif'].find((name) => existsSync(join(dir, name)));
13
- const no = (reason) => ({ done: false, reason });
13
+ const no = (reason) => ({ done: false, reason, downgraded: [] });
14
+
15
+ // Beats covered by the subject devices (the count the downgrade rule compares).
16
+ const subjectBeats = (t) => new Set((t?.visualDevices ?? []).filter((d) => d?.role === 'subject').flatMap((d) => d?.beats ?? [])).size;
17
+
18
+ // Downgrades from the first valid treatment to the final one. Recorded on the
19
+ // result, never a reason to un-count the render.
20
+ export function downgrades(first, final) {
21
+ if (!first || !final) return [];
22
+ const items = [];
23
+ if (final.durationSec < first.durationSec * 0.8) items.push(`the film was shortened from ${first.durationSec} s to ${final.durationSec} s`);
24
+ if (subjectBeats(final) < subjectBeats(first)) items.push(`the subject is on screen in fewer beats (${subjectBeats(first)} -> ${subjectBeats(final)})`);
25
+ if (final.sound?.mode === 'none' && first.sound?.mode !== 'none') items.push('the sound was switched to none');
26
+ if (first.path === 'stage' && final.path === 'type') items.push('the path moved from stage to type');
27
+ return items;
28
+ }
29
+
30
+ function downgraded(out) {
31
+ try {
32
+ const first = JSON.parse(readFileSync(join(out, '.run', 'treatment-first.json'), 'utf8'));
33
+ const final = JSON.parse(readFileSync(join(out, 'treatment.json'), 'utf8'));
34
+ return downgrades(first, final);
35
+ } catch { return []; }
36
+ }
37
+
38
+ const withDowngrades = (out, result) => {
39
+ const down = downgraded(out);
40
+ return { ...result, downgraded: down, reason: down.length ? `${result.reason}; downgraded: ${down.join('; ')}` : result.reason };
41
+ };
14
42
 
15
43
  export function checkCompletion(out) {
16
44
  const statePath = join(out, 'run-state.json');
17
45
  if (!existsSync(statePath)) return no('no run-state.json: the render command never finished');
18
46
  const state = JSON.parse(readFileSync(statePath, 'utf8'));
19
47
  if (state.exitCode === null || state.exitCode === undefined || !state.finished) return no('the render command started but did not finish');
20
- if (state.mode === 'stills-only' || state.mode === 'stills' || state.mode === 'sheet') return no('a stills-only look pass is not a delivered film');
48
+ if (state.mode === 'stills-only' || state.mode === 'stills' || state.mode === 'sheet') {
49
+ // A film whose treatment asks for a preview is done at its board: a
50
+ // stills-only render whose layout check passed, looked at, storyboard included.
51
+ if (state.exitCode === 0) {
52
+ try {
53
+ const treatment = JSON.parse(readFileSync(join(out, 'treatment.json'), 'utf8'));
54
+ if (treatment?.preview === true) {
55
+ const check = JSON.parse(readFileSync(join(out, 'stills', 'check.json'), 'utf8'));
56
+ const failed = (check.rules || []).filter((rule) => rule.status === 'FAIL');
57
+ if (!failed.length && existsSync(join(out, 'stills', 'board.png'))) return withDowngrades(out, { done: true, reason: 'preview film: board check passed' });
58
+ return no(`the preview board check did not pass (${failed.map((rule) => rule.id).join(', ') || 'no board'}); fix the page and render --stills-only again`);
59
+ }
60
+ } catch { /* fall through to the stills-only rule */ }
61
+ }
62
+ return no('a stills-only look pass is not a delivered film');
63
+ }
21
64
  if ([10, 11, 12, 14, 15].includes(state.exitCode)) return no(`BLOCKED exit ${state.exitCode}: fix the named prerequisite and rerun`);
22
65
  if (state.exitCode !== 0 && state.exitCode !== 13) return no(`the command exited ${state.exitCode}`);
23
66
  const manifestPath = join(out, 'manifest.json'), reportPath = join(out, 'gate-report.txt');
@@ -37,16 +80,16 @@ export function checkCompletion(out) {
37
80
  const withheld = ARTIFACTS.every((name) => existsSync(join(withheldDir, name))) && previewName(withheldDir);
38
81
  if (state.exitCode === 0) {
39
82
  if (!/QA gate: PASS/.test(report)) return no('exit 0 without a passing gate report');
40
- return delivered ? { done: true, reason: 'gate passed; four artifacts delivered' } : no('gate passed but an artifact is missing from its deliverable name');
83
+ return delivered ? withDowngrades(out, { done: true, reason: 'gate passed; four artifacts delivered' }) : no('gate passed but an artifact is missing from its deliverable name');
41
84
  }
42
85
  if ((state.round ?? manifest.lithermes?.round ?? 1) < 3) return no(`gate failed in round ${state.round}; fix the named rules and rerun with --round ${(state.round || 1) + 1}`);
43
86
  if (flash) {
44
87
  if (delivered || ['film.mp4', 'poster.png', 'preview.webp', 'preview.gif'].some((name) => existsSync(join(out, name)))) return no('a flash-failing export sits at a deliverable name');
45
88
  if (!withheld) return no('flash failure without the exports in withheld/');
46
89
  if (!/WITHHELD/.test(report)) return no('the report does not say the render is withheld');
47
- return { done: true, reason: 'round 3 flash failure: exports withheld pending a fix' };
90
+ return withDowngrades(out, { done: true, reason: 'round 3 flash failure: exports withheld pending a fix' });
48
91
  }
49
92
  if (!delivered) return no('round 3 failure but the artifacts are not at their deliverable names');
50
93
  if (!failed.length || !failed.every((id) => report.includes(id))) return no('the report does not name the failed rules');
51
- return { done: true, reason: `round 3 ended with ${failed.join(', ')} failing; artifacts delivered with the report` };
94
+ return withDowngrades(out, { done: true, reason: `round 3 ended with ${failed.join(', ')} failing; artifacts delivered with the report` });
52
95
  }
@@ -429,6 +429,19 @@ const LABELS = {
429
429
  const MAIN_ORDER = ['MO-C-01', 'MO-C-02', 'MO-C-03', 'MO-C-04', 'MO-C-05', 'MO-C-06', 'MO-C-07/08', 'MO-C-09', 'MO-C-10/11/12', 'MO-C-13', 'MO-C-14',
430
430
  'MO-C-25', 'MO-C-26', 'MO-C-27', 'MO-C-29', 'MO-D-02', 'MO-D-03', 'MO-D-04'];
431
431
 
432
+ export const GATE_IDS = Object.freeze([...MAIN_ORDER]);
433
+
434
+ // Older notes name six text rules with no runtime of their own. Each names a
435
+ // real check below; references/studio.md carries the same table for readers.
436
+ export const DOC_GATES = Object.freeze({
437
+ 'TEXT-OVERLAP': { kind: 'qa-row', id: 'text-overlap' },
438
+ 'TEXT-ON-STYLE': { kind: 'qa-row', id: 'fonts' },
439
+ 'TEXT-ZONE': { kind: 'qa-row', id: 'text-title-safe' },
440
+ 'FLAT-FRAME': { kind: 'gate', id: 'MO-D-03' },
441
+ 'DEAD-AIR': { kind: 'qa-row', id: 'non-text-presence' },
442
+ 'RICH-FILL': { kind: 'treatment', id: 'palette' },
443
+ });
444
+
432
445
  export function renderReport(manifest, gate, { outputs = {}, framesViewed = [], egress = 'websocket', withheld = false, preflight = false } = {}) {
433
446
  const lx = manifest.lithermes || {};
434
447
  const byId = new Map(gate.rules.map((r) => [r.id, r]));
@@ -0,0 +1,44 @@
1
+ // The house file: `motion-house.json` beside a film's output folder, in the user's directory.
2
+ // A full render that passes its gate writes it, or appends the film to it. While it exists,
3
+ // the treatment validator holds every new film in that folder to its style unless the request
4
+ // names another. It is validator input, so the second film inherits the first one's style
5
+ // without anyone having to carry it over.
6
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { STYLE_IDS } from './styles.mjs';
9
+
10
+ export const HOUSE_FILE = 'motion-house.json';
11
+ export const HOUSE_SCHEMA = 'litfamily.motion-house/v1';
12
+ const MAX_FILMS = 50;
13
+
14
+ export const houseFileFor = (out) => path.join(path.dirname(path.resolve(out)), HOUSE_FILE);
15
+
16
+ /** The house record for an output folder, or null when missing or unusable. */
17
+ export function readHouse(out) {
18
+ const file = houseFileFor(out);
19
+ if (!existsSync(file)) return null;
20
+ try {
21
+ const house = JSON.parse(readFileSync(file, 'utf8'));
22
+ if (house?.schema !== HOUSE_SCHEMA || !STYLE_IDS.includes(house.style)) return null;
23
+ return house;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Record a passed film. The first film fixes the house style; later films append. A film
31
+ * whose style the request named outright appends without moving the house style.
32
+ */
33
+ export function recordHouse(out, { style, path: renderPath, format, durationSec, now = new Date() }) {
34
+ const file = houseFileFor(out);
35
+ const house = readHouse(out) ?? { schema: HOUSE_SCHEMA, style, createdAt: now.toISOString(), films: [] };
36
+ const dir = path.basename(path.resolve(out));
37
+ const films = (Array.isArray(house.films) ? house.films : []).filter((film) => film?.dir !== dir);
38
+ films.push({ dir, style, path: renderPath, format, durationSec, at: now.toISOString() });
39
+ const next = { ...house, films: films.slice(-MAX_FILMS) };
40
+ const tmp = `${file}.tmp`;
41
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
42
+ renameSync(tmp, file);
43
+ return next;
44
+ }