@litfamily/lithermes 1.0.29 → 1.0.31
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.
- package/README.md +12 -9
- package/README_Ko-KR.md +12 -9
- package/assets/lithermes-plugin/README.md +15 -2
- package/assets/lithermes-plugin/__init__.py +49 -10
- package/assets/lithermes-plugin/adhd_stack.py +272 -0
- package/assets/lithermes-plugin/bounded_work.py +41 -0
- package/assets/lithermes-plugin/core.py +53 -4
- package/assets/lithermes-plugin/core_commands.py +99 -0
- package/assets/lithermes-plugin/core_contexts.py +54 -4
- package/assets/lithermes-plugin/core_contract.py +2 -2
- package/assets/lithermes-plugin/core_plans.py +80 -0
- package/assets/lithermes-plugin/core_routing.py +126 -0
- package/assets/lithermes-plugin/core_workflow_commands.py +11 -5
- package/assets/lithermes-plugin/diagnostics.py +3 -2
- package/assets/lithermes-plugin/lit_mark.py +75 -0
- package/assets/lithermes-plugin/payload-version.json +473 -61
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/scientific_visualization.py +23 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
- package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +202 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +78 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +97 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +56 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +650 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
- package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
- package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +34 -1
- package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
- package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
- package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- 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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
43
|
-
2.
|
|
44
|
-
3.
|
|
45
|
-
4.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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()
|