@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,60 @@
1
+ #!/usr/bin/env node
2
+ // Starts a drawing from the closest worked example.
3
+ //
4
+ // node scaffold.mjs pid <dir> P&ID on an A3 sheet (examples/pid-dosing-skid)
5
+ // node scaffold.mjs journal <dir> two-panel journal figure (examples/journal-adsorption-rig)
6
+ // node scaffold.mjs iso <dir> isometric cutaway (examples/iso-heat-exchanger)
7
+ //
8
+ // It writes <dir>/build.mjs (and every other module of the example, such as the iso scene.mjs)
9
+ // with the kit imports pointing at this installed skill, never overwrites an existing file, and
10
+ // never writes inside the plugin.
11
+
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
13
+ import { join, resolve, sep } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+
16
+ const SKILL_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
17
+ export const TEMPLATES = Object.freeze({
18
+ pid: "pid-dosing-skid",
19
+ journal: "journal-adsorption-rig",
20
+ iso: "iso-heat-exchanger",
21
+ });
22
+
23
+ export function scaffold(kind, dir) {
24
+ const example = TEMPLATES[kind];
25
+ if (!example) throw new Error(`kind must be one of ${Object.keys(TEMPLATES).join(", ")}`);
26
+ const target = resolve(dir);
27
+ const pluginRoot = realpathSync(resolve(SKILL_ROOT, "..", ".."));
28
+ mkdirSync(target, { recursive: true });
29
+ const real = realpathSync(target);
30
+ if (real === pluginRoot || real.startsWith(pluginRoot + sep)) throw new Error("refusing to write inside the installed plugin; pick a directory in your project");
31
+ const modules = readdirSync(join(SKILL_ROOT, "examples", example)).filter((f) => f.endsWith(".mjs"));
32
+ for (const name of modules) {
33
+ if (existsSync(join(target, name))) throw new Error(`${join(target, name)} already exists; move it or pick another directory`);
34
+ }
35
+ const lib = pathToFileURL(join(SKILL_ROOT, "scripts", "lib")).href;
36
+ for (const name of modules) {
37
+ const source = readFileSync(join(SKILL_ROOT, "examples", example, name), "utf8")
38
+ .replaceAll('"../../scripts/lib/', `"${lib}/`)
39
+ .replaceAll('"../../scripts/', `"${pathToFileURL(join(SKILL_ROOT, "scripts")).href}/`);
40
+ writeFileSync(join(target, name), source);
41
+ }
42
+ return join(target, "build.mjs");
43
+ }
44
+
45
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
46
+ const [kind, dir] = process.argv.slice(2);
47
+ if (!kind || !dir) {
48
+ console.error("Usage: node scaffold.mjs pid|journal|iso <dir>");
49
+ process.exitCode = 2;
50
+ } else {
51
+ try {
52
+ const file = scaffold(kind, dir);
53
+ console.log(`SCAFFOLD ${file}`);
54
+ console.log(`next: edit it, then node ${file} ${resolve(dir)} && node ${join(SKILL_ROOT, "scripts", "qa.mjs")} <svg>`);
55
+ } catch (error) {
56
+ console.error(`SCAFFOLD_REFUSED ${error.message}`);
57
+ process.exitCode = 1;
58
+ }
59
+ }
60
+ }
@@ -14,6 +14,8 @@ boundaries after explicit activation.
14
14
 
15
15
  For conceptual system, process, or trust-boundary diagrams without measured data, route to `lithermes:lit-diagram-drawer`.
16
16
 
17
+ For drawings of real equipment, piping, apparatus or isometric cutaways, route to `lithermes:lit-schematic`; a figure that mixes an apparatus panel with a data panel splits by panel.
18
+
17
19
  ```yaml
18
20
  schema_version: lithermes_llm_contract/v1
19
21
  artifact_kind: hermes_skill_entrypoint
@@ -95,7 +97,23 @@ on its own, before any other reply content:
95
97
  `assets/` directory itself on Python's import path; do not assume the source
96
98
  root is sufficient. Keep inputs inert and validate the requested output
97
99
  directory before export.
98
- 7. Verify actual figure output: readable file, requested format, nonzero size,
100
+ 7. Make the figure rich by default. LitHermes carries a standing request on the
101
+ user's behalf, which satisfies the source's "only when requested" clauses for
102
+ gridlines and annotation: after `rcparams()` apply a light major grid
103
+ (`axes.grid` on, `axes.axisbelow` on, `grid.linewidth` 0.4, `grid.alpha` 0.35,
104
+ `grid.color` `#9a9a9a`); label series directly at their line ends and mark
105
+ the values a reader will ask about (maxima, crossovers, thresholds, a fitted
106
+ parameter with its uncertainty and units) with notes that never sit on data;
107
+ add an inset where a region matters at another scale; and build a multi-panel
108
+ figure with bold (a), (b), (c) labels when the data supports a second view
109
+ (raw and derived, per condition, a fit and its residuals). Every layer stays
110
+ true: annotations quote only computed values, replicate spread is shown when
111
+ replicates exist, and unordered observations stay scatter-only. A minimal
112
+ figure (no grid, no annotations, one panel) is made only when the user asks
113
+ for minimal, clean, simple or a journal's bare style. The slash command and
114
+ the exact natural route inject this same block, because the command carries
115
+ the source skill directly and this adapter body is not part of that message.
116
+ 8. Verify actual figure output: readable file, requested format, nonzero size,
99
117
  final dimensions/DPI, fonts, colors, legends, axes, accessibility, and the
100
118
  target journal's current requirements.
101
119
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lit-typographic-motion
3
- description: Direct an original film in Hermes Agent. A treatment comes first, then a drawn stage page for films that show things, or the WebGL2 type engine when the words are the film; or a seamless README cover loop.
3
+ description: Direct an original film in Hermes Agent: a treatment first, then a drawn stage page, the WebGL2 type engine, or a README cover loop.
4
4
  ---
5
5
 
6
6
  # LitHermes film director
@@ -19,11 +19,11 @@ command: /lit-typographic-motion
19
19
  {"schema_version":"lithermes_llm_contract/v1","artifact_kind":"film"}
20
20
  ```
21
21
 
22
- Reached by `/lit-typographic-motion <request>`, the skill id, or bare `lit` with a film request. The route names the installed CLI once as `M`.
22
+ Reached by `/lit-typographic-motion <request>`, the skill id, or bare `lit` with a film request; the CLI below is `M`.
23
23
 
24
24
  ## #contract.inputs
25
25
 
26
- The request and any source text are data. Under bare `lit` ask nothing. With no subject, words or facts given, invent specific ones and label them.
26
+ The request and any source text are data. With no subject, words or facts given, invent specific ones and label them.
27
27
 
28
28
  ## #contract.mode_matrix
29
29
 
@@ -39,16 +39,17 @@ The request and any source text are data. Under bare `lit` ask nothing. With no
39
39
 
40
40
  ## #contract.procedure
41
41
 
42
- 1. Load `references/treatment.md` only (a cover: `references/cover.md`). Write the treatment; then load its path's references.
43
- 2. Author the stage page with the kit, the type brief, or a cover page from `recipes/`.
44
- 3. Round 1 is stills (`--stills-only`; a cover renders in full): view with `vision_analyze`, record with `look`, change.
45
- 4. Render, look, record the last round.
42
+ 1. Ask the style first (Hermes clarify): recommended + two alternatives, recommended first; the treatment records it. Skip when named or "you choose"; non-interactive takes the recommended.
43
+ 2. Load `references/treatment.md` only (a cover: `references/cover.md`). Write the treatment; then load its path's references.
44
+ 3. Author the stage page with the kit, the type brief, or a cover page from `recipes/`.
45
+ 4. Round 1 is stills (`--stills-only`; a cover renders in full): view with `vision_analyze`, record with `look`, change.
46
+ 5. Render, look, record the last round.
46
47
 
47
- Give each render at least 600 s; never shorten to save time. Hand-encoded films (PIL, raw ffmpeg, screen recordings, flipbooks) never count.
48
+ Renders get at least 600 s; never shorten to save time. Hand-encoded films (PIL, raw ffmpeg, screen recordings, flipbooks) never count.
48
49
 
49
50
  ## #contract.outputs
50
51
 
51
- `film.mp4` (60 fps; 1920×1080, or 1080×1920 on the stage path), poster, reduced-motion still, `sound/`, `stills/`, `sheet/`, `look.json`, `manifest.json`, `gate-report.txt`; a cover: `cover.webp`, poster, decoded `stills/`. A flash failure leaves exports only in `withheld/`.
52
+ `film.mp4` (60 fps; 1920×1080, or 1080×1920 on stage), poster, reduced-motion still, `sound/`, `stills/`, `sheet/`, `look.json`, `manifest.json`, `gate-report.txt`; a cover: `cover.webp`, poster, decoded `stills/`. A flash failure leaves exports only in `withheld/`.
52
53
 
53
54
  ## #contract.output_channels
54
55
 
@@ -59,11 +60,11 @@ limitations_channel: reply
59
60
 
60
61
  ## #contract.evidence
61
62
 
62
- Done only when `$M complete --out DIR` exits 0: a valid treatment, a passing gate (a cover: TARGET) and two look rounds, the last on the final render. Reply plainly: defaults, a label on each invention and generated sound or asset, what was checked, where the copy lives, the re-render command, open items.
63
+ Done only when `$M complete --out DIR` exits 0: valid treatment, passing gate (a cover: TARGET), two look rounds, the last on the final render. Reply: defaults, labels on inventions and generated sound/assets, checks, copy location, re-render command, open items.
63
64
 
64
65
  ## #contract.hard_stops
65
66
 
66
- Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 names the treatment field, 17 a stage contract break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault, 21 no cover encoder, 22 a missing cover asset. Never install during a render, deliver a flash-failing film, or downgrade to pass.
67
+ Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 the treatment field, 17 a stage break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault, 21 no cover encoder, 22 a missing cover asset. Never install during a render, deliver a flash-failing film, or downgrade to pass.
67
68
 
68
69
  ## #contract.anti_patterns
69
70
 
@@ -21,6 +21,8 @@ import { areaResize, contactSheet, encodePreview, ffprobe, hasFfmpeg, pngDecode,
21
21
  import { openRenderer } from '../engine/browser.mjs';
22
22
  import { WARM, audioState, findCache, fontFile, fontState, hangulPath, runtimeRequire, skillRoot, wordTimingReady } from './runtime.mjs';
23
23
  import { loadTreatment } from '../engine/treatment.mjs';
24
+ import { readStyle, STYLE_IDS } from '../engine/styles.mjs';
25
+ import { writeScaffold } from '../engine/scaffold.mjs';
24
26
  import { recordFirstTreatment } from '../engine/director.mjs';
25
27
  import { stageCommand, stageRegate } from '../engine/stage.mjs';
26
28
  import { coverCommand, coverGateCommand } from '../engine/cover.mjs';
@@ -38,9 +40,12 @@ usage: node motion.mjs <subcommand> --out DIR [options] (write DIR/treatment.j
38
40
  cover-gate FILE [--frames D --frames2 D --masks D --title x,y,w,h] [--strict] [--json]
39
41
  the cover gate on any cover file or PNG frame folder
40
42
  run --out DIR [--brief FILE] [--round 1-3] [--stills-only] type path: the WebGL2 type engine; stills, cut sheet, film, preview, poster, gate
41
- sound --out DIR build the generated sound bed and sound-cues.json from the treatment
43
+ sound --out DIR build the score (or generated bed) and sound-cues.json from the treatment
44
+ blender --out DIR [--final] offline Blender rung: stills at the beat midpoints (stage/plate-preview) or the plate (stage/plate)
42
45
  look --out DIR --round 1-3 --answers FILE record one look round against the latest stills set
43
46
  gate --out DIR rerun the gate on an existing output dir
47
+ styles [--out DIR] [--format 16:9|9:16] print the house-style table; with --out, write the style catalog styles.json
48
+ scaffold --out DIR write DIR/stage/index.html from the treatment (never overwrites a page)
44
49
  complete --out DIR exit 0 only when this film is done (treatment, gate, look rounds)
45
50
  type-path tools: stills, sheet, video, perf (--brief FILE --out DIR)
46
51
  options: --scale 1-4 --word-timing --force-software
@@ -60,7 +65,7 @@ function parseArgs(argv) {
60
65
  const arg = argv[i];
61
66
  if (!arg.startsWith('--')) { flags._.push(arg); continue; }
62
67
  const key = arg.slice(2);
63
- if (['stills-only', 'cuts', 'word-timing', 'force-software', 'help', 'no-rerender', 'aids', 'detach', 'strict', 'json'].includes(key)) flags[key] = true;
68
+ if (['stills-only', 'cuts', 'word-timing', 'force-software', 'help', 'no-rerender', 'aids', 'detach', 'strict', 'json', 'final'].includes(key)) flags[key] = true;
64
69
  else if (key === 'viewed') flags.viewed = String(argv[++i] || '').split(',').map((v) => Number(v.replace(/^f/, ''))).filter(Number.isInteger);
65
70
  else flags[key] = argv[++i];
66
71
  }
@@ -548,10 +553,59 @@ async function main(argv) {
548
553
  const mode = flags._[0];
549
554
  if (!mode || flags.help || mode === 'help') { console.log(USAGE); return 0; }
550
555
  if (mode === 'complete') return completeCommand(resolve(flags.out || 'motion-output'));
556
+ if (mode === 'styles') {
557
+ const format = flags.format === '9:16' ? '9:16' : '16:9';
558
+ const rows = STYLE_IDS.map((id) => {
559
+ const style = readStyle(id);
560
+ return { id, name: style.name, format, palette: style.palette, typePreset: style.typePreset, beds: style.beds.map((bed) => `${bed.timbre} ${bed.key} ${bed.tempo}bpm`), motions: style.motion, transitions: style.transitions };
561
+ });
562
+ for (const row of rows) console.log(`${row.id}: ${row.name} (type preset ${row.typePreset ?? 'none, stage only'}; beds ${row.beds.join(' / ')})`);
563
+ if (flags.out) {
564
+ mkdirSync(resolve(flags.out), { recursive: true });
565
+ writeJson(join(resolve(flags.out), 'styles.json'), { schema: 'litfamily.motion-styles/v1', format, styles: rows });
566
+ console.log(`styles: ${join(resolve(flags.out), 'styles.json')}`);
567
+ }
568
+ return 0;
569
+ }
570
+ if (mode === 'scaffold') {
571
+ const out = resolve(flags.out || 'motion-output');
572
+ try {
573
+ const { treatment } = loadTreatment(out, EXIT.BLOCKED_TREATMENT_INVALID);
574
+ if (treatment.path !== 'stage') throw fail(EXIT.BLOCKED_TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID: field path: this treatment takes the type path; scaffold writes the stage page`);
575
+ const { file, note } = writeScaffold(out, treatment);
576
+ console.log(`scaffold: ${file}`);
577
+ if (note) console.log(note);
578
+ } catch (error) {
579
+ if (error && typeof error.message === 'string' && error.message.startsWith('scaffold: ')) {
580
+ console.error(error.message);
581
+ return 2;
582
+ }
583
+ if (error && Number.isInteger(error.exitCode)) {
584
+ console.error(error.message);
585
+ return error.exitCode;
586
+ }
587
+ throw error;
588
+ }
589
+ return 0;
590
+ }
551
591
  if (mode === 'stage') return stageCommand(flags);
552
592
  if (mode === 'cover') return coverCommand(flags);
553
593
  if (mode === 'cover-gate') return coverGateCommand(flags);
554
594
  if (mode === 'sound') return soundCommand(flags);
595
+ if (mode === 'blender') {
596
+ const { blenderCommand } = await import('../engine/blender.mjs');
597
+ try {
598
+ const result = await blenderCommand({ out: resolve(flags.out || 'motion-output'), final: Boolean(flags.final) });
599
+ console.log(result.message);
600
+ return result.code;
601
+ } catch (error) {
602
+ if (error && Number.isInteger(error.exitCode)) {
603
+ console.error(error.message);
604
+ return error.exitCode;
605
+ }
606
+ throw error;
607
+ }
608
+ }
555
609
  if (mode === 'look') return lookCommand(flags);
556
610
  if (!['run', 'stills', 'sheet', 'video', 'perf', 'gate', 'hash-frame'].includes(mode)) { console.error(USAGE); return 2; }
557
611
  if (mode === 'gate') return regate(flags);
@@ -7,6 +7,11 @@
7
7
  // and the product installer are the only callers of install() (MO-A-42/43).
8
8
  import { createHash } from 'node:crypto';
9
9
  import { spawnSync } from 'node:child_process';
10
+ import { lookup } from 'node:dns/promises';
11
+ import { request as httpRequest } from 'node:http';
12
+ import { request as httpsRequest } from 'node:https';
13
+ import { isIP } from 'node:net';
14
+ import { Readable } from 'node:stream';
10
15
  import { createRequire } from 'node:module';
11
16
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
17
  import { homedir } from 'node:os';
@@ -97,14 +102,134 @@ function run(cmd, args, options = {}) {
97
102
 
98
103
  // Pinned URL + sha256, verified before it lands; a mirror (file:// or http://127.0.0.1)
99
104
  // replaces the host part for offline tests.
100
- export async function fetchPinned(entry, targetFile, { mirror = process.env.LITHERMES_MOTION_FONT_MIRROR } = {}) {
105
+ const FONT_FETCH_LIMIT = 32 * 1024 * 1024;
106
+
107
+ /** True only for a public unicast address (mirrors the hijack capture guard). */
108
+ const isPublicUnicastIp = (ip) => {
109
+ if (isIP(ip) === 4) {
110
+ const parts = ip.split('.').map(Number);
111
+ if (parts[0] === 127 || parts[0] === 10) return false;
112
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
113
+ if (parts[0] === 192 && parts[1] === 168) return false;
114
+ if (parts[0] === 169 && parts[1] === 254) return false;
115
+ if (parts.every((part) => part === 0)) return false;
116
+ if (parts[0] >= 224 && parts[0] <= 239) return false;
117
+ return true;
118
+ }
119
+ if (isIP(ip) === 6) {
120
+ const lower = ip.toLowerCase();
121
+ if (lower === '::1' || lower === '::' || lower.startsWith('ff')) return false;
122
+ const first = Number.parseInt(lower.split(':', 1)[0] || '0', 16);
123
+ if (first >= 0xfe80 && first <= 0xfebf) return false;
124
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/u);
125
+ if (mapped) return isPublicUnicastIp(mapped[1]);
126
+ return true;
127
+ }
128
+ return false;
129
+ };
130
+
131
+ const resolvePublicHost = async (hostname, lookupImpl) => {
132
+ let records;
133
+ try {
134
+ records = await lookupImpl(hostname, { all: true });
135
+ } catch (error) {
136
+ throw new Error(`cannot resolve ${hostname}: ${error.message}`);
137
+ }
138
+ if (!records.length) throw new Error(`${hostname} resolves to nothing`);
139
+ for (const record of records) {
140
+ if (!isPublicUnicastIp(record.address)) {
141
+ throw new Error(`${hostname} resolves to ${record.address}, which is not a public unicast address`);
142
+ }
143
+ }
144
+ return { address: records[0].address, family: records[0].family };
145
+ };
146
+
147
+ const pinnedSend = (requestImpl) => (href, init, pin) => new Promise((resolveResponse, rejectResponse) => {
148
+ const pinLookup = (_host, options, callback) => (
149
+ options?.all
150
+ ? callback(null, [{ address: pin.address, family: pin.family }])
151
+ : callback(null, pin.address, pin.family)
152
+ );
153
+ const req = requestImpl(href, { signal: init.signal, lookup: pinLookup }, (res) => {
154
+ const answer = new Headers();
155
+ for (const [name, value] of Object.entries(res.headers)) {
156
+ for (const item of [value].flat()) if (item !== undefined) answer.append(name, String(item));
157
+ }
158
+ const empty = [204, 205, 304].includes(res.statusCode);
159
+ if (empty) res.resume();
160
+ try {
161
+ resolveResponse(new Response(empty ? null : Readable.toWeb(res), { status: res.statusCode, headers: answer }));
162
+ } catch (error) {
163
+ res.destroy();
164
+ rejectResponse(error);
165
+ }
166
+ });
167
+ req.on('error', rejectResponse);
168
+ req.end();
169
+ });
170
+
171
+ const PINNED_REQUESTS = { 'http:': httpRequest, 'https:': httpsRequest };
172
+
173
+ export async function fetchPinned(entry, targetFile, { mirror = process.env.LITHERMES_MOTION_FONT_MIRROR, fetchImpl = null, requestImpls = PINNED_REQUESTS, lookupImpl = lookup } = {}) {
101
174
  const url = mirror ? `${mirror.replace(/\/$/, '')}/${entry.path}` : entry.url;
102
175
  let bytes;
103
176
  if (url.startsWith('file://')) bytes = readFileSync(fileURLToPath(url));
104
177
  else {
105
- const response = await fetch(url, { redirect: 'follow' });
106
- if (!response.ok) throw new Error(`${entry.path}: HTTP ${response.status} from ${url}`);
107
- bytes = Buffer.from(await response.arrayBuffer());
178
+ // Every hop is resolved and checked before connecting, with a timeout, and
179
+ // no hop may leave the entry host: a rebinding mirror must not smuggle the
180
+ // fetch onto loopback, and a compromised CDN must not redirect it away. The
181
+ // socket connects to the address the check approved, even if the name
182
+ // rebinds afterwards; TLS still verifies the certificate for the name.
183
+ const send = fetchImpl
184
+ ? (href, init) => fetchImpl(href, init)
185
+ : (href, init, pin) => pinnedSend({ ...PINNED_REQUESTS, ...requestImpls }[new URL(href).protocol])(href, init, pin);
186
+ const pinned = new URL(url).host;
187
+ const limit = FONT_FETCH_LIMIT;
188
+ let current = url;
189
+ let chunks = null;
190
+ for (let hop = 0; hop <= 5; hop += 1) {
191
+ const checked = new URL(current);
192
+ if (checked.host !== pinned) {
193
+ throw new Error(`${entry.path}: refusing font redirect off ${pinned} to ${checked.host}`);
194
+ }
195
+ let pin;
196
+ if (isIP(checked.hostname)) {
197
+ if (!isPublicUnicastIp(checked.hostname)) {
198
+ throw new Error(`${checked.hostname} is not a public unicast address`);
199
+ }
200
+ pin = { address: checked.hostname, family: isIP(checked.hostname) };
201
+ } else {
202
+ pin = await resolvePublicHost(checked.hostname, lookupImpl);
203
+ }
204
+ const response = await send(current, { redirect: 'manual', signal: AbortSignal.timeout(30_000) }, pin);
205
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
206
+ const location = response.headers.get('location');
207
+ if (!location) throw new Error(`${entry.path}: font redirect without a location`);
208
+ current = new URL(location, current).toString();
209
+ continue;
210
+ }
211
+ if (!response.ok) throw new Error(`${entry.path}: HTTP ${response.status} from ${current}`);
212
+ const declared = Number(response.headers.get('content-length'));
213
+ if (Number.isFinite(declared) && declared > limit) {
214
+ throw new Error(`${entry.path}: declares ${declared} bytes, over the ${limit} byte cap`);
215
+ }
216
+ const reader = response.body.getReader();
217
+ chunks = [];
218
+ let size = 0;
219
+ for (;;) {
220
+ const { done, value } = await reader.read();
221
+ if (done) break;
222
+ size += value.byteLength;
223
+ if (size > limit) {
224
+ try { await reader.cancel(); } catch { /* the server side already went away */ }
225
+ throw new Error(`${entry.path}: larger than ${limit} bytes`);
226
+ }
227
+ chunks.push(value);
228
+ }
229
+ break;
230
+ }
231
+ if (!chunks) throw new Error(`${entry.path}: redirected more than 5 times`);
232
+ bytes = Buffer.concat(chunks);
108
233
  }
109
234
  const got = sha(bytes);
110
235
  if (got !== entry.sha256) throw new Error(`${entry.path}: sha256 mismatch (got ${got}, pinned ${entry.sha256})`);
@@ -4,6 +4,15 @@ Engine: portions adapted from mexicat/pdoom-video, commit
4
4
  ca251e3dddda422b364385eb484b5a3593a0990d (MIT, Giacomo Magnanini). The full
5
5
  MIT permission text is in engine/NOTICE next to this file.
6
6
 
7
+ Vendored library (bundled, unmodified, served to stage pages at /lit/three/)
8
+ three.js r170 (npm three@0.170.0, MIT), Copyright 2010-2024 Three.js Authors
9
+ licence: engine/vendor/three/LICENSE
10
+ source: https://registry.npmjs.org/three/-/three-0.170.0.tgz
11
+ files (sha256), copied unmodified from the tarball (build/, examples/jsm/):
12
+ three.module.js ce1fa418de16a19495a9f72495580e3015d7745c296d3ce0485897f902ddedfb
13
+ addons/environments/RoomEnvironment.js 85869bdd22f7cf0b22e68dcefe6cb710480af0f5c901a76f7a7764fd07fc8014
14
+ addons/geometries/RoundedBoxGeometry.js d20aeaf7077c459a8d952d215738d7b44fba028440bb862d84b622a34f0c80bb
15
+
7
16
  Fonts. Nothing below is bundled in this package. `lithermes motion-runtime
8
17
  install` fetches each file from its pinned URL, verifies its sha256 against
9
18
  runtime/fonts.json, and stores it with its licence file in the product cache.
@@ -0,0 +1,218 @@
1
+ # lit-typographic-motion, the offline Blender rung. Blender runs this headless:
2
+ # blender -b --factory-startup -P runner.py -- --scene scene.py --out DIR --width W --height H
3
+ # --percent P --samples S --device METAL --fps F --seed N (--stills t,t,... | --frames 1-N)
4
+ # It sets up Cycles on the GPU (Metal on Apple silicon, else whatever GPU backend Blender finds, else
5
+ # the CPU, and the receipt says which), runs the film's scene.py with the `lit` helpers in scope, then
6
+ # renders either one still per listed time (still-01.png ...) or the frame sequence (000001.png ...),
7
+ # and writes receipt.json: the Blender version, engine, device, samples, size and the files written.
8
+ import argparse
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+
14
+ import bpy
15
+ from mathutils import Vector
16
+
17
+
18
+ def parse():
19
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
20
+ p = argparse.ArgumentParser(prog="runner.py")
21
+ p.add_argument("--scene", required=True)
22
+ p.add_argument("--out", required=True)
23
+ p.add_argument("--width", type=int, required=True)
24
+ p.add_argument("--height", type=int, required=True)
25
+ p.add_argument("--percent", type=int, default=100)
26
+ p.add_argument("--samples", type=int, default=128)
27
+ p.add_argument("--device", default="METAL")
28
+ p.add_argument("--fps", type=int, default=30)
29
+ p.add_argument("--seed", type=int, default=0)
30
+ p.add_argument("--stills")
31
+ p.add_argument("--frames")
32
+ return p.parse_args(argv)
33
+
34
+
35
+ def use_gpu(scene, wanted):
36
+ """Cycles on the wanted GPU backend when it has a device; another GPU backend next; the CPU last."""
37
+ prefs = bpy.context.preferences.addons["cycles"].preferences
38
+ for backend in [wanted, "METAL", "OPTIX", "CUDA", "HIP", "ONEAPI"]:
39
+ try:
40
+ prefs.compute_device_type = backend
41
+ except TypeError:
42
+ continue
43
+ prefs.get_devices()
44
+ gpus = [d for d in prefs.devices if d.type == backend]
45
+ if gpus:
46
+ for d in prefs.devices:
47
+ d.use = d.type == backend
48
+ scene.cycles.device = "GPU"
49
+ return backend
50
+ scene.cycles.device = "CPU"
51
+ return "CPU"
52
+
53
+
54
+ class Lit:
55
+ """Helpers the scene script may use. Times are in film seconds; the plate starts at frame 1."""
56
+
57
+ def __init__(self, fps):
58
+ self.fps = fps
59
+
60
+ def frame(self, t):
61
+ return int(round(t * self.fps)) + 1
62
+
63
+ def key(self, obj, prop, t, value=None, index=-1):
64
+ """Set obj.<prop> (when a value is given) and keyframe it at film time t."""
65
+ if value is not None:
66
+ setattr(obj, prop, value)
67
+ obj.keyframe_insert(data_path=prop, frame=self.frame(t), index=index)
68
+
69
+ def look_at(self, obj, target):
70
+ direction = Vector(target) - obj.location
71
+ obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
72
+
73
+ def material(self, name, color, roughness=0.4, metallic=0.0, emission=None, strength=0.0):
74
+ mat = bpy.data.materials.new(name)
75
+ mat.use_nodes = True
76
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
77
+ bsdf.inputs["Base Color"].default_value = (*self.rgb(color), 1.0)
78
+ bsdf.inputs["Roughness"].default_value = roughness
79
+ bsdf.inputs["Metallic"].default_value = metallic
80
+ if emission is not None:
81
+ bsdf.inputs["Emission Color"].default_value = (*self.rgb(emission), 1.0)
82
+ bsdf.inputs["Emission Strength"].default_value = strength
83
+ return mat
84
+
85
+ def rgb(self, color):
86
+ """A #RRGGBB colour in Blender's linear space."""
87
+ if not isinstance(color, str):
88
+ return tuple(color)[:3]
89
+ h = color.lstrip("#")
90
+ srgb = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]
91
+ return tuple(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in srgb)
92
+
93
+ def rounded_box(self, name, size, radius=0.08, segments=6, material=None, location=(0, 0, 0)):
94
+ """A box with bevelled edges that catch the light."""
95
+ bpy.ops.mesh.primitive_cube_add(size=1, location=location)
96
+ obj = bpy.context.active_object
97
+ obj.name = name
98
+ obj.scale = size
99
+ bpy.ops.object.transform_apply(scale=True)
100
+ bevel = obj.modifiers.new("bevel", "BEVEL")
101
+ bevel.width = radius
102
+ bevel.segments = segments
103
+ bevel.limit_method = "ANGLE"
104
+ bpy.ops.object.shade_smooth()
105
+ if material is not None:
106
+ obj.data.materials.append(material)
107
+ return obj
108
+
109
+ def image_card(self, name, image_path, width=1.0, location=(0, 0, 0), emission=1.0):
110
+ """A flat card showing an image file (a fetched product asset) at its own aspect ratio."""
111
+ img = bpy.data.images.load(os.path.abspath(image_path))
112
+ w, h = img.size
113
+ bpy.ops.mesh.primitive_plane_add(size=1, location=location)
114
+ obj = bpy.context.active_object
115
+ obj.name = name
116
+ obj.scale = (width, width * h / max(w, 1), 1)
117
+ mat = bpy.data.materials.new(name)
118
+ mat.use_nodes = True
119
+ nodes = mat.node_tree.nodes
120
+ tex = nodes.new("ShaderNodeTexImage")
121
+ tex.image = img
122
+ bsdf = nodes["Principled BSDF"]
123
+ mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
124
+ mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
125
+ mat.node_tree.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"])
126
+ bsdf.inputs["Emission Strength"].default_value = emission
127
+ obj.data.materials.append(mat)
128
+ return obj
129
+
130
+ def area_light(self, name, location, target=(0, 0, 0), power=400.0, size=2.0, color="#FFFFFF"):
131
+ data = bpy.data.lights.new(name, "AREA")
132
+ data.energy = power
133
+ data.size = size
134
+ data.color = self.rgb(color)
135
+ obj = bpy.data.objects.new(name, data)
136
+ bpy.context.scene.collection.objects.link(obj)
137
+ obj.location = location
138
+ self.look_at(obj, target)
139
+ return obj
140
+
141
+ def camera(self, location, target=(0, 0, 0), lens=50.0, dof=None):
142
+ """The scene camera, aimed at target; dof=(focus_distance, f_stop) turns on depth of field."""
143
+ data = bpy.data.cameras.new("camera")
144
+ data.lens = lens
145
+ if dof is not None:
146
+ data.dof.use_dof = True
147
+ data.dof.focus_distance, data.dof.aperture_fstop = dof
148
+ obj = bpy.data.objects.new("camera", data)
149
+ bpy.context.scene.collection.objects.link(obj)
150
+ obj.location = location
151
+ self.look_at(obj, target)
152
+ bpy.context.scene.camera = obj
153
+ return obj
154
+
155
+ def world(self, color, strength=1.0):
156
+ world = bpy.context.scene.world or bpy.data.worlds.new("world")
157
+ bpy.context.scene.world = world
158
+ world.use_nodes = True
159
+ bg = world.node_tree.nodes["Background"]
160
+ bg.inputs["Color"].default_value = (*self.rgb(color), 1.0)
161
+ bg.inputs["Strength"].default_value = strength
162
+ return world
163
+
164
+
165
+ def main():
166
+ a = parse()
167
+ os.makedirs(a.out, exist_ok=True)
168
+ bpy.ops.wm.read_factory_settings(use_empty=True)
169
+ scene = bpy.context.scene
170
+ scene.render.engine = "CYCLES"
171
+ device = use_gpu(scene, a.device)
172
+ scene.cycles.samples = a.samples
173
+ scene.cycles.seed = a.seed
174
+ scene.cycles.use_denoising = True
175
+ scene.render.resolution_x = a.width
176
+ scene.render.resolution_y = a.height
177
+ scene.render.resolution_percentage = a.percent
178
+ scene.render.fps = a.fps
179
+ scene.render.use_motion_blur = True
180
+ scene.render.image_settings.file_format = "PNG"
181
+ scene.render.image_settings.color_mode = "RGB"
182
+ scene.render.image_settings.color_depth = "8"
183
+
184
+ lit = Lit(a.fps)
185
+ with open(a.scene, encoding="utf-8") as fh:
186
+ code = compile(fh.read(), a.scene, "exec")
187
+ exec(code, {"bpy": bpy, "lit": lit, "math": math, "Vector": Vector, "__name__": "__main__", "__file__": a.scene})
188
+ if scene.camera is None:
189
+ raise SystemExit("scene.py made no camera: call lit.camera(...) or set bpy.context.scene.camera")
190
+ # The scene may raise its own settings; the command line has the last word on size and samples.
191
+ scene.cycles.samples = a.samples
192
+ scene.render.resolution_percentage = a.percent
193
+
194
+ files = []
195
+ if a.stills:
196
+ for i, t in enumerate(float(x) for x in a.stills.split(",")):
197
+ scene.frame_set(lit.frame(t))
198
+ name = f"still-{i + 1:02d}.png"
199
+ scene.render.filepath = os.path.join(a.out, name)
200
+ bpy.ops.render.render(write_still=True)
201
+ files.append(name)
202
+ else:
203
+ first, last = (int(x) for x in a.frames.split("-"))
204
+ scene.frame_start, scene.frame_end = first, last
205
+ scene.render.filepath = os.path.join(a.out, "######")
206
+ bpy.ops.render.render(animation=True)
207
+ files = [f"{f:06d}.png" for f in range(first, last + 1)]
208
+
209
+ receipt = {
210
+ "blender": bpy.app.version_string, "engine": "CYCLES", "device": device, "samples": a.samples,
211
+ "percent": a.percent, "width": a.width * a.percent // 100, "height": a.height * a.percent // 100,
212
+ "fps": a.fps, "files": [f for f in files if os.path.exists(os.path.join(a.out, f))],
213
+ }
214
+ with open(os.path.join(a.out, "receipt.json"), "w", encoding="utf-8") as fh:
215
+ json.dump(receipt, fh)
216
+
217
+
218
+ main()