@litfamily/litopencode 1.0.30 → 1.0.32
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/CHANGELOG.md +28 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +48 -1
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/ledger.js +12 -1
- package/dist/secret-shapes.js +5 -5
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +156 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +2 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +20 -1
- package/dist/tui/mission-band.js +73 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +48 -0
- package/dist/tui/mission-source.js +184 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +50 -0
- package/dist/tui/mission-threads.js +119 -0
- package/dist/tui.js +84 -6
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +30 -2
- package/docs/reference.md +34 -2
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +208 -0
- package/skills/lit-adhd/references/brief-format.md +43 -0
- package/skills/lit-adhd/references/card-examples.md +82 -0
- package/skills/lit-adhd/references/status-taxonomy.md +29 -0
- package/skills/lit-adhd/scripts/rewind.mjs +835 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +2 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/requirements-audio.txt +3 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +520 -91
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
- package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +4 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
# Cycles goes on the GPU (the wanted backend first, then any other that has a device, else the CPU,
|
|
5
|
+
# and the receipt names which). The film's scene.py then runs with the `lit` helpers in scope, and the
|
|
6
|
+
# runner renders 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
|
+
GPU_BACKENDS = ("METAL", "OPTIX", "CUDA", "HIP", "ONEAPI")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def arguments():
|
|
21
|
+
rest = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
22
|
+
parser = argparse.ArgumentParser(prog="runner.py")
|
|
23
|
+
parser.add_argument("--scene", required=True)
|
|
24
|
+
parser.add_argument("--out", required=True)
|
|
25
|
+
parser.add_argument("--width", type=int, required=True)
|
|
26
|
+
parser.add_argument("--height", type=int, required=True)
|
|
27
|
+
parser.add_argument("--percent", type=int, default=100)
|
|
28
|
+
parser.add_argument("--samples", type=int, default=128)
|
|
29
|
+
parser.add_argument("--device", default="METAL")
|
|
30
|
+
parser.add_argument("--fps", type=int, default=30)
|
|
31
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
32
|
+
parser.add_argument("--stills")
|
|
33
|
+
parser.add_argument("--frames")
|
|
34
|
+
return parser.parse_args(rest)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def use_gpu(scene, wanted):
|
|
38
|
+
"""Put Cycles on a GPU backend that has a device, the wanted one first; the CPU when none does."""
|
|
39
|
+
prefs = bpy.context.preferences.addons["cycles"].preferences
|
|
40
|
+
for backend in dict.fromkeys((wanted, *GPU_BACKENDS)):
|
|
41
|
+
try:
|
|
42
|
+
prefs.compute_device_type = backend
|
|
43
|
+
except TypeError:
|
|
44
|
+
continue
|
|
45
|
+
prefs.get_devices()
|
|
46
|
+
chosen = [d for d in prefs.devices if d.type == backend]
|
|
47
|
+
if chosen:
|
|
48
|
+
for d in prefs.devices:
|
|
49
|
+
d.use = d.type == backend
|
|
50
|
+
scene.cycles.device = "GPU"
|
|
51
|
+
return backend
|
|
52
|
+
scene.cycles.device = "CPU"
|
|
53
|
+
return "CPU"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def srgb_to_linear(value):
|
|
57
|
+
return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Lit:
|
|
61
|
+
"""What scene.py may call. Times are film seconds; the plate's first frame is frame 1."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, fps):
|
|
64
|
+
self.fps = fps
|
|
65
|
+
|
|
66
|
+
def frame(self, t):
|
|
67
|
+
return int(round(t * self.fps)) + 1
|
|
68
|
+
|
|
69
|
+
def key(self, obj, prop, t, value=None, index=-1):
|
|
70
|
+
"""Set obj.<prop> when a value is given, then keyframe it at film time t."""
|
|
71
|
+
if value is not None:
|
|
72
|
+
setattr(obj, prop, value)
|
|
73
|
+
obj.keyframe_insert(data_path=prop, frame=self.frame(t), index=index)
|
|
74
|
+
|
|
75
|
+
def rgb(self, color):
|
|
76
|
+
"""A #RRGGBB string, or a tuple already in linear space, as Blender's linear colour."""
|
|
77
|
+
if not isinstance(color, str):
|
|
78
|
+
return tuple(color)[:3]
|
|
79
|
+
digits = color.lstrip("#")
|
|
80
|
+
return tuple(srgb_to_linear(int(digits[i:i + 2], 16) / 255) for i in (0, 2, 4))
|
|
81
|
+
|
|
82
|
+
def look_at(self, obj, target):
|
|
83
|
+
aim = Vector(target) - obj.location
|
|
84
|
+
obj.rotation_euler = aim.to_track_quat("-Z", "Y").to_euler()
|
|
85
|
+
|
|
86
|
+
def material(self, name, color, roughness=0.4, metallic=0.0, emission=None, strength=0.0):
|
|
87
|
+
mat = bpy.data.materials.new(name)
|
|
88
|
+
mat.use_nodes = True
|
|
89
|
+
shader = mat.node_tree.nodes["Principled BSDF"]
|
|
90
|
+
shader.inputs["Base Color"].default_value = (*self.rgb(color), 1.0)
|
|
91
|
+
shader.inputs["Roughness"].default_value = roughness
|
|
92
|
+
shader.inputs["Metallic"].default_value = metallic
|
|
93
|
+
if emission is not None:
|
|
94
|
+
shader.inputs["Emission Color"].default_value = (*self.rgb(emission), 1.0)
|
|
95
|
+
shader.inputs["Emission Strength"].default_value = strength
|
|
96
|
+
return mat
|
|
97
|
+
|
|
98
|
+
def rounded_box(self, name, size, radius=0.08, segments=6, material=None, location=(0, 0, 0)):
|
|
99
|
+
"""A box whose bevelled edges catch the light."""
|
|
100
|
+
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
|
|
101
|
+
obj = bpy.context.active_object
|
|
102
|
+
obj.name = name
|
|
103
|
+
obj.scale = size
|
|
104
|
+
bpy.ops.object.transform_apply(scale=True)
|
|
105
|
+
bevel = obj.modifiers.new("bevel", "BEVEL")
|
|
106
|
+
bevel.width = radius
|
|
107
|
+
bevel.segments = segments
|
|
108
|
+
bevel.limit_method = "ANGLE"
|
|
109
|
+
bpy.ops.object.shade_smooth()
|
|
110
|
+
if material is not None:
|
|
111
|
+
obj.data.materials.append(material)
|
|
112
|
+
return obj
|
|
113
|
+
|
|
114
|
+
def image_card(self, name, image_path, width=1.0, location=(0, 0, 0), emission=1.0):
|
|
115
|
+
"""A flat card showing a product image file at its own aspect ratio, lit from within."""
|
|
116
|
+
image = bpy.data.images.load(os.path.abspath(image_path))
|
|
117
|
+
w, h = image.size
|
|
118
|
+
bpy.ops.mesh.primitive_plane_add(size=1, location=location)
|
|
119
|
+
obj = bpy.context.active_object
|
|
120
|
+
obj.name = name
|
|
121
|
+
obj.scale = (width, width * h / max(w, 1), 1)
|
|
122
|
+
mat = bpy.data.materials.new(name)
|
|
123
|
+
mat.use_nodes = True
|
|
124
|
+
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
|
125
|
+
texture = nodes.new("ShaderNodeTexImage")
|
|
126
|
+
texture.image = image
|
|
127
|
+
shader = nodes["Principled BSDF"]
|
|
128
|
+
links.new(texture.outputs["Color"], shader.inputs["Base Color"])
|
|
129
|
+
links.new(texture.outputs["Color"], shader.inputs["Emission Color"])
|
|
130
|
+
links.new(texture.outputs["Alpha"], shader.inputs["Alpha"])
|
|
131
|
+
shader.inputs["Emission Strength"].default_value = emission
|
|
132
|
+
obj.data.materials.append(mat)
|
|
133
|
+
return obj
|
|
134
|
+
|
|
135
|
+
def area_light(self, name, location, target=(0, 0, 0), power=400.0, size=2.0, color="#FFFFFF"):
|
|
136
|
+
data = bpy.data.lights.new(name, "AREA")
|
|
137
|
+
data.energy = power
|
|
138
|
+
data.size = size
|
|
139
|
+
data.color = self.rgb(color)
|
|
140
|
+
obj = bpy.data.objects.new(name, data)
|
|
141
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
142
|
+
obj.location = location
|
|
143
|
+
self.look_at(obj, target)
|
|
144
|
+
return obj
|
|
145
|
+
|
|
146
|
+
def camera(self, location, target=(0, 0, 0), lens=50.0, dof=None):
|
|
147
|
+
"""The scene camera aimed at target; dof=(focus_distance, f_stop) turns depth of field on."""
|
|
148
|
+
data = bpy.data.cameras.new("camera")
|
|
149
|
+
data.lens = lens
|
|
150
|
+
if dof is not None:
|
|
151
|
+
data.dof.use_dof = True
|
|
152
|
+
data.dof.focus_distance, data.dof.aperture_fstop = dof
|
|
153
|
+
obj = bpy.data.objects.new("camera", data)
|
|
154
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
155
|
+
obj.location = location
|
|
156
|
+
self.look_at(obj, target)
|
|
157
|
+
bpy.context.scene.camera = obj
|
|
158
|
+
return obj
|
|
159
|
+
|
|
160
|
+
def world(self, color, strength=1.0):
|
|
161
|
+
scene = bpy.context.scene
|
|
162
|
+
scene.world = scene.world or bpy.data.worlds.new("world")
|
|
163
|
+
scene.world.use_nodes = True
|
|
164
|
+
background = scene.world.node_tree.nodes["Background"]
|
|
165
|
+
background.inputs["Color"].default_value = (*self.rgb(color), 1.0)
|
|
166
|
+
background.inputs["Strength"].default_value = strength
|
|
167
|
+
return scene.world
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def configure(scene, a):
|
|
171
|
+
scene.render.engine = "CYCLES"
|
|
172
|
+
device = use_gpu(scene, a.device)
|
|
173
|
+
scene.cycles.samples = a.samples
|
|
174
|
+
scene.cycles.seed = a.seed
|
|
175
|
+
scene.cycles.use_denoising = True
|
|
176
|
+
scene.render.resolution_x = a.width
|
|
177
|
+
scene.render.resolution_y = a.height
|
|
178
|
+
scene.render.resolution_percentage = a.percent
|
|
179
|
+
scene.render.fps = a.fps
|
|
180
|
+
scene.render.use_motion_blur = True
|
|
181
|
+
image = scene.render.image_settings
|
|
182
|
+
image.file_format, image.color_mode, image.color_depth = "PNG", "RGB", "8"
|
|
183
|
+
return device
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def render_stills(scene, lit, a):
|
|
187
|
+
names = []
|
|
188
|
+
for index, t in enumerate(float(x) for x in a.stills.split(",")):
|
|
189
|
+
scene.frame_set(lit.frame(t))
|
|
190
|
+
names.append(f"still-{index + 1:02d}.png")
|
|
191
|
+
scene.render.filepath = os.path.join(a.out, names[-1])
|
|
192
|
+
bpy.ops.render.render(write_still=True)
|
|
193
|
+
return names
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_frames(scene, a):
|
|
197
|
+
first, last = (int(x) for x in a.frames.split("-"))
|
|
198
|
+
scene.frame_start, scene.frame_end = first, last
|
|
199
|
+
scene.render.filepath = os.path.join(a.out, "######")
|
|
200
|
+
bpy.ops.render.render(animation=True)
|
|
201
|
+
return [f"{n:06d}.png" for n in range(first, last + 1)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main():
|
|
205
|
+
a = arguments()
|
|
206
|
+
os.makedirs(a.out, exist_ok=True)
|
|
207
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
208
|
+
scene = bpy.context.scene
|
|
209
|
+
device = configure(scene, a)
|
|
210
|
+
lit = Lit(a.fps)
|
|
211
|
+
with open(a.scene, encoding="utf-8") as handle:
|
|
212
|
+
code = compile(handle.read(), a.scene, "exec")
|
|
213
|
+
exec(code, {"bpy": bpy, "lit": lit, "math": math, "Vector": Vector, "__name__": "__main__", "__file__": a.scene})
|
|
214
|
+
if scene.camera is None:
|
|
215
|
+
raise SystemExit("scene.py made no camera: call lit.camera(...) or set bpy.context.scene.camera")
|
|
216
|
+
scene.cycles.samples = a.samples # the command line has the last word on quality
|
|
217
|
+
scene.render.resolution_percentage = a.percent
|
|
218
|
+
names = render_stills(scene, lit, a) if a.stills else render_frames(scene, a)
|
|
219
|
+
receipt = {
|
|
220
|
+
"blender": bpy.app.version_string, "engine": "CYCLES", "device": device, "samples": a.samples,
|
|
221
|
+
"percent": a.percent, "width": a.width * a.percent // 100, "height": a.height * a.percent // 100,
|
|
222
|
+
"fps": a.fps, "files": [n for n in names if os.path.exists(os.path.join(a.out, n))],
|
|
223
|
+
}
|
|
224
|
+
with open(os.path.join(a.out, "receipt.json"), "w", encoding="utf-8") as handle:
|
|
225
|
+
json.dump(receipt, handle)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The offline Blender rung. A 3D film with `render3d: "blender"` writes its world as a Blender Python
|
|
2
|
+
// scene (<out>/stage/scene.py); `render.mjs blender` renders it headless with Cycles on the GPU through
|
|
3
|
+
// the bundled runner: first preview stills at the shot midpoints (half size, few samples) to look at,
|
|
4
|
+
// then with --final the full plate sequence in <out>/stage/plate/ with plate.json, the receipt the
|
|
5
|
+
// stage trusts. The stage page shows the plate through LitStage.plate() and draws its type and labels
|
|
6
|
+
// over it, so the page itself stays on the software rung. Without Blender the command renders nothing
|
|
7
|
+
// and names the fallback: the three.js world on the GPU rung.
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { EXIT } from "./constants.mjs";
|
|
13
|
+
import { StageError, readPlate } from "./stage/scan.mjs";
|
|
14
|
+
import { frameSize, loadTreatment } from "./treatment.mjs";
|
|
15
|
+
|
|
16
|
+
export { readPlate };
|
|
17
|
+
|
|
18
|
+
export const BLENDER = Object.freeze({
|
|
19
|
+
macApp: "/Applications/Blender.app/Contents/MacOS/Blender",
|
|
20
|
+
runner: fileURLToPath(new URL("./blender/runner.py", import.meta.url)),
|
|
21
|
+
plateFps: 30,
|
|
22
|
+
device: "METAL",
|
|
23
|
+
preview: Object.freeze({ percent: 50, samples: 40 }),
|
|
24
|
+
final: Object.freeze({ percent: 100, samples: 160 })
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Where Blender is: an explicit LITOPENCODE_BLENDER path (used only if it exists, never second-guessed),
|
|
28
|
+
// then the macOS app, then `blender` on PATH. Returns { path, source } or null.
|
|
29
|
+
export function findBlender({ env = process.env, exists = existsSync, platform = process.platform } = {}) {
|
|
30
|
+
if (env.LITOPENCODE_BLENDER) return exists(env.LITOPENCODE_BLENDER) ? { path: env.LITOPENCODE_BLENDER, source: "LITOPENCODE_BLENDER" } : null;
|
|
31
|
+
if (platform === "darwin" && exists(BLENDER.macApp)) return { path: BLENDER.macApp, source: "app" };
|
|
32
|
+
for (const dir of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
33
|
+
const file = path.join(dir, "blender");
|
|
34
|
+
if (exists(file)) return { path: file, source: "PATH" };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const plateFrames = (treatment) => Math.round(treatment.durationSec * BLENDER.plateFps);
|
|
40
|
+
|
|
41
|
+
// The seed the stage page and the plate share, below 2^31 because Cycles takes a signed int.
|
|
42
|
+
export const plateSeed = (seed) => (seed >>> 0) % 2147483648;
|
|
43
|
+
|
|
44
|
+
// The headless command line: factory settings, the runner, and the runner's own arguments after `--`.
|
|
45
|
+
export function blenderArgs({ treatment, scene, out, quality }) {
|
|
46
|
+
const q = BLENDER[quality];
|
|
47
|
+
if (quality !== "preview" && quality !== "final") throw new Error(`blenderArgs: quality is preview or final (got ${quality})`);
|
|
48
|
+
const { width, height } = frameSize(treatment);
|
|
49
|
+
const args = ["-b", "--factory-startup", "-P", BLENDER.runner, "--",
|
|
50
|
+
"--scene", scene, "--out", out, "--width", String(width), "--height", String(height),
|
|
51
|
+
"--percent", String(q.percent), "--samples", String(q.samples), "--device", BLENDER.device,
|
|
52
|
+
"--fps", String(BLENDER.plateFps), "--seed", String(plateSeed(Number.isSafeInteger(treatment.seed) ? treatment.seed : 0))];
|
|
53
|
+
if (quality === "preview") args.push("--stills", treatment.beats.map((b) => +((b.t0 + b.t1) / 2).toFixed(3)).join(","));
|
|
54
|
+
else args.push("--frames", `1-${plateFrames(treatment)}`);
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FALLBACK = (out) => [
|
|
59
|
+
"FALLBACK: no Blender found (LITOPENCODE_BLENDER, /Applications/Blender.app, blender on PATH), so nothing was rendered.",
|
|
60
|
+
`Set render3d to "three" in ${path.join(out, "treatment.json")} and build the world in three.js on the GPU rung (references/depth.md),`,
|
|
61
|
+
"and say in the reply that the film used the GPU rung because Blender is not installed."
|
|
62
|
+
].join("\n");
|
|
63
|
+
|
|
64
|
+
const usage = (detail) => Object.assign(new Error(detail), { exitCode: 2 });
|
|
65
|
+
const listPngs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".png")).sort() : []);
|
|
66
|
+
|
|
67
|
+
function runBlender({ bin, args, dir, expected, progress }) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
70
|
+
let tail = "";
|
|
71
|
+
const keep = (chunk) => { tail = (tail + chunk).slice(-4000); };
|
|
72
|
+
child.stdout.on("data", keep);
|
|
73
|
+
child.stderr.on("data", keep);
|
|
74
|
+
const timer = progress ? setInterval(() => progress(listPngs(dir).length, expected), 2000) : null;
|
|
75
|
+
const done = (value) => { if (timer) clearInterval(timer); resolve(value); };
|
|
76
|
+
child.on("close", (code) => done({ code, tail }));
|
|
77
|
+
child.on("error", (error) => done({ code: -1, tail: error.message }));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `render.mjs blender --out <dir> [--final]`. Preview: stills in stage/plate-preview/. Final: the plate
|
|
82
|
+
// sequence in stage/plate/ and plate.json listing every frame by name and size, with the device,
|
|
83
|
+
// samples and Blender version from the runner's receipt. Returns { code, message }.
|
|
84
|
+
export async function blenderCommand({ out, final = false, env = process.env, log = () => {}, progress = null }) {
|
|
85
|
+
const { treatment } = loadTreatment(out);
|
|
86
|
+
if (treatment.path !== "stage" || treatment.render3d !== "blender") throw usage(`blender renders a stage treatment with render3d "blender" (this one is ${treatment.path === "stage" ? (treatment.render3d ?? "three") : "a type-path film"})`);
|
|
87
|
+
const stageDir = path.join(out, "stage");
|
|
88
|
+
const scene = path.join(stageDir, "scene.py");
|
|
89
|
+
if (!existsSync(scene)) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${scene} is missing; write the Blender scene first (references/depth.md, the Blender rung)`);
|
|
90
|
+
const found = findBlender({ env });
|
|
91
|
+
if (!found) return { code: EXIT.OK, message: FALLBACK(out) };
|
|
92
|
+
const quality = final ? "final" : "preview";
|
|
93
|
+
const dir = path.join(stageDir, final ? "plate" : "plate-preview");
|
|
94
|
+
rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const args = blenderArgs({ treatment, scene, out: dir, quality });
|
|
97
|
+
const expected = final ? plateFrames(treatment) : treatment.beats.length;
|
|
98
|
+
log(`blender ${quality}: ${found.path} (${found.source}), ${expected} ${final ? "frames" : "stills"}`);
|
|
99
|
+
const run = await runBlender({ bin: found.path, args, dir, expected, progress });
|
|
100
|
+
const receiptFile = path.join(dir, "receipt.json");
|
|
101
|
+
if (run.code !== 0 || !existsSync(receiptFile)) {
|
|
102
|
+
throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `Blender ${quality} failed (exit ${run.code}); fix stage/scene.py and run it again. Last output: ${run.tail.trim().split("\n").slice(-8).join(" | ")}`);
|
|
103
|
+
}
|
|
104
|
+
const receipt = JSON.parse(readFileSync(receiptFile, "utf8"));
|
|
105
|
+
const frames = receipt.files.map((name) => ({ name, bytes: statSync(path.join(dir, name)).size }));
|
|
106
|
+
if (frames.length !== expected) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `Blender wrote ${frames.length} of ${expected} ${final ? "frames" : "stills"}`);
|
|
107
|
+
const plate = { kind: quality, blender: receipt.blender, engine: receipt.engine, device: receipt.device, samples: receipt.samples, percent: receipt.percent, fps: BLENDER.plateFps, frames };
|
|
108
|
+
writeFileSync(path.join(dir, "plate.json"), `${JSON.stringify(plate, null, 1)}\n`);
|
|
109
|
+
const label = `Blender ${receipt.blender}, ${receipt.engine} ${receipt.device}, ${receipt.samples} spp at ${receipt.percent} %`;
|
|
110
|
+
if (!final) {
|
|
111
|
+
return { code: EXIT.OK, message: `BLENDER preview: ${frames.length} stills at the shot midpoints in ${dir} (${label}). Open them with the read tool, compare them with any reference frames the user gave, fix scene.py, then render the plate: blender --out ${out} --final` };
|
|
112
|
+
}
|
|
113
|
+
return { code: EXIT.OK, message: `BLENDER plate: ${frames.length} frames at ${BLENDER.plateFps} fps in ${dir} (${label}). Show it with LitStage.plate() under the type, then render the stage: stage --out ${out} --round N` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A Blender film needs its whole plate before the stage starts: returns the plate, throws exit 17 when
|
|
117
|
+
// plate.json is missing, does not match its frames, or counts a different number of frames than the
|
|
118
|
+
// film's length needs. Any other film returns null.
|
|
119
|
+
export function requirePlate(treatment, stageDir) {
|
|
120
|
+
if (treatment.render3d !== "blender") return null;
|
|
121
|
+
const plate = readPlate(stageDir, "plate");
|
|
122
|
+
if (!plate) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `render3d is blender but stage/plate/plate.json is missing or does not match its frames; render the plate first: blender --out <dir> --final (no Blender: set render3d to "three" and use the GPU rung)`);
|
|
123
|
+
const want = plateFrames(treatment);
|
|
124
|
+
if (plate.frames.length !== want) throw new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `the plate has ${plate.frames.length} frames; a ${treatment.durationSec} s film needs ${want} at ${BLENDER.plateFps} fps (render it again: blender --out <dir> --final)`);
|
|
125
|
+
return plate;
|
|
126
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The storyboard: `stills/board.png`, one page a person can read before the full render. One keyframe
|
|
2
|
+
// per shot (the beat still) with the shot's number, time, layout, hand-off and hero move under it and
|
|
3
|
+
// the shot's type zone outlined on the frame; a header with the house style (name, palette, type) and
|
|
4
|
+
// the film's format, length and sound; a footer with the layout check. It is drawn as a page in the same
|
|
5
|
+
// headless Chrome the stage uses, so its type is the product's own faces.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { LAYOUTS, readStyle } from "./styles.mjs";
|
|
10
|
+
import { openStage } from "./stage/capture.mjs";
|
|
11
|
+
|
|
12
|
+
export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
|
|
13
|
+
|
|
14
|
+
const esc = (text) => String(text ?? "").replace(/[&<>"]/gu, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
15
|
+
const secs = (v) => `${Number(v).toFixed(1)}`;
|
|
16
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
17
|
+
|
|
18
|
+
// Tile geometry (columns, thumbnail size) for n shots of a film of size width x height.
|
|
19
|
+
export function boardGrid(n, width, height) {
|
|
20
|
+
const usableW = BOARD.width - 2 * BOARD.margin;
|
|
21
|
+
const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
|
|
22
|
+
const aspect = width / height;
|
|
23
|
+
const options = [];
|
|
24
|
+
for (let cols = 1; cols <= Math.max(1, n); cols++) {
|
|
25
|
+
const rows = Math.ceil(n / cols);
|
|
26
|
+
const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
|
|
27
|
+
const byHeight = Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption;
|
|
28
|
+
const th = Math.min(Math.round(tw / aspect), byHeight);
|
|
29
|
+
if (th <= 0) continue;
|
|
30
|
+
options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
|
|
31
|
+
}
|
|
32
|
+
// The largest tiles, unless a grid with fewer empty slots keeps 85 % of their area.
|
|
33
|
+
const largest = Math.max(...options.map((o) => o.tw * o.th));
|
|
34
|
+
return options.filter((o) => o.tw * o.th >= 0.85 * largest).sort((a, b) => a.empty - b.empty || b.tw * b.th - a.tw * a.th)[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The layout check as board rules: every gate row of the text QA that is not N/A.
|
|
38
|
+
export function boardRules(checks) {
|
|
39
|
+
return Object.entries(checks).filter(([, row]) => !row.na).map(([id, row]) => ({ id, status: row.pass ? "PASS" : row.warn ? "WARN" : "FAIL", detail: row.detail }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The board page's HTML for a treatment and its beat stills (file names relative to the page).
|
|
43
|
+
export function boardHtml({ treatment, stills, rules = null }) {
|
|
44
|
+
const style = treatment.style ? readStyle(treatment.style) : null;
|
|
45
|
+
const format = treatment.format === "9:16" ? "9:16" : "16:9";
|
|
46
|
+
const [width, height] = format === "9:16" ? [1080, 1920] : [1920, 1080];
|
|
47
|
+
const grid = boardGrid(stills.length, width, height);
|
|
48
|
+
const zones = LAYOUTS[format];
|
|
49
|
+
const pal = style?.palette ?? {};
|
|
50
|
+
const chips = Object.entries(pal).filter(([k]) => ["ground", "ink", "muted", "signal", "accent"].includes(k))
|
|
51
|
+
.map(([k, v]) => `<span class="chip"><i style="background:${esc(v)}"></i>${esc(k)} ${esc(v)}</span>`).join("");
|
|
52
|
+
const tiles = stills.map((file, i) => {
|
|
53
|
+
const b = treatment.beats[i] ?? {};
|
|
54
|
+
const z = b.layout && zones[b.layout]?.type;
|
|
55
|
+
const guide = z ? `<b class="zone" style="left:${(z[0] * 100).toFixed(2)}%;top:${(z[1] * 100).toFixed(2)}%;width:${(z[2] * 100).toFixed(2)}%;height:${(z[3] * 100).toFixed(2)}%"></b>` : "";
|
|
56
|
+
const meta = [String(i + 1).padStart(2, "0"), `${secs(b.t0)}–${secs(b.t1)} s`, b.layout, b.transition && b.transition !== "end" ? `→ ${b.transition}` : b.transition].filter(Boolean).map(esc).join(" · ");
|
|
57
|
+
return `<figure style="width:${grid.tw}px"><div class="frame" style="width:${grid.tw}px;height:${grid.th}px"><img src="${esc(file)}">${guide}</div>`
|
|
58
|
+
+ `<figcaption><span class="meta">${meta}</span><span class="hero">${esc(b.hero ?? b.onScreen ?? "")}</span></figcaption></figure>`;
|
|
59
|
+
}).join("");
|
|
60
|
+
const failed = (rules ?? []).filter((x) => x.status === "FAIL");
|
|
61
|
+
const verdict = !rules ? "layout check: not run on this render"
|
|
62
|
+
: failed.length ? `layout check: FAIL — ${failed.map((x) => x.id).join(", ")}` : "layout check: PASS";
|
|
63
|
+
const sound = treatment.sound?.mode === "generated" && treatment.sound.palette
|
|
64
|
+
? `${treatment.sound.palette}, ${treatment.sound.key}, ${treatment.sound.tempo} BPM`
|
|
65
|
+
: treatment.sound?.mode ?? "none";
|
|
66
|
+
const ground = pal.ground ?? "#101216";
|
|
67
|
+
return `<!doctype html>
|
|
68
|
+
<meta charset="utf-8">
|
|
69
|
+
<link rel="stylesheet" href="/lit/fonts.css">
|
|
70
|
+
<script src="/lit/stage-kit.js"></script>
|
|
71
|
+
<style>
|
|
72
|
+
html, body { margin: 0; width: ${BOARD.width}px; height: ${BOARD.height}px; overflow: hidden; background: #15171b; color: #e8e9e6; font-family: "Pretendard"; }
|
|
73
|
+
header { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: 36px; height: ${BOARD.header - 48}px; display: flex; justify-content: space-between; align-items: flex-start; }
|
|
74
|
+
h1 { margin: 0; font: 700 40px/1.1 "Pretendard"; }
|
|
75
|
+
.sub { margin-top: 10px; font: 400 20px/1.3 "MesloLGS NF"; color: #a8aba6; }
|
|
76
|
+
.style { text-align: right; }
|
|
77
|
+
.specimen { display: inline-block; padding: 6px 14px; margin-bottom: 10px; background: ${esc(ground)}; color: ${esc(pal.ink ?? "#fff")}; font: ${style?.faces.display.weight ?? 700} 34px/1.1 "${esc(style?.faces.display.family ?? "Pretendard")}", "Pretendard"; }
|
|
78
|
+
.chips { display: flex; gap: 14px; justify-content: flex-end; font: 400 16px/1 "MesloLGS NF"; color: #a8aba6; }
|
|
79
|
+
.chip i { display: inline-block; width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; border: 1px solid #555; }
|
|
80
|
+
main { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: ${BOARD.header}px; display: flex; flex-wrap: wrap; gap: ${BOARD.gap}px; }
|
|
81
|
+
figure { margin: 0; }
|
|
82
|
+
.frame { position: relative; overflow: hidden; background: #000; }
|
|
83
|
+
.frame img { display: block; width: 100%; height: 100%; }
|
|
84
|
+
.zone { position: absolute; box-sizing: border-box; border: 2px dashed rgba(255, 196, 0, 0.75); }
|
|
85
|
+
figcaption { display: block; height: ${BOARD.caption - 8}px; padding-top: 8px; overflow: hidden; }
|
|
86
|
+
.meta { display: block; font: 400 16px/1.3 "MesloLGS NF"; color: #c9cbc6; }
|
|
87
|
+
.hero { display: block; margin-top: 4px; font: 400 18px/1.35 "Pretendard"; color: #e8e9e6; }
|
|
88
|
+
footer { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; bottom: 18px; font: 400 18px/1.2 "MesloLGS NF"; color: ${failed.length ? "#ff8a7a" : "#9fd3a4"}; }
|
|
89
|
+
</style>
|
|
90
|
+
<body>
|
|
91
|
+
<header>
|
|
92
|
+
<div><h1>Storyboard · ${esc(treatment.subject?.name ?? "")}</h1>
|
|
93
|
+
<div class="sub">${esc(treatment.format)} · ${esc(treatment.durationSec)} s · ${treatment.beats.length} shots · ${esc(treatment.path)} path · sound: ${esc(sound)}</div></div>
|
|
94
|
+
<div class="style"><div class="specimen">${esc(style?.name ?? treatment.style ?? "")} Aa 가</div><div class="chips">${chips}</div></div>
|
|
95
|
+
</header>
|
|
96
|
+
<main>${tiles}</main>
|
|
97
|
+
<footer>${esc(verdict)} · dashed box: the shot's type zone</footer>
|
|
98
|
+
<script>LitStage.define({ width: ${BOARD.width}, height: ${BOARD.height}, fps: 30, duration: 1, render() {} });</script>
|
|
99
|
+
</body>
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Draws `<out>/stills/board.png` from the beat stills already written. `context` is the stage context
|
|
104
|
+
// (Chrome path, faces); `rules` is the layout check when one ran. Returns the file path.
|
|
105
|
+
export async function writeBoard({ out, context, chromium, treatment, beatFiles, rules = null }) {
|
|
106
|
+
const dir = path.join(out, ".run", "board");
|
|
107
|
+
rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
const names = beatFiles.map((file, i) => {
|
|
110
|
+
const name = `s${String(i + 1).padStart(2, "0")}.png`;
|
|
111
|
+
copyFileSync(file, path.join(dir, name));
|
|
112
|
+
return name;
|
|
113
|
+
});
|
|
114
|
+
writeFileSync(path.join(dir, "index.html"), boardHtml({ treatment, stills: names, rules }));
|
|
115
|
+
const stage = await openStage(chromium, { chromePath: context.chromePath, profile: path.join(out, ".run", "bd"), stageDir: dir, width: BOARD.width, height: BOARD.height, fps: 30, seed: 1, fonts: context.fonts });
|
|
116
|
+
try {
|
|
117
|
+
await stage.load(names.map((name) => `/${name}`));
|
|
118
|
+
await stage.step(0, true);
|
|
119
|
+
const file = path.join(out, "stills", "board.png");
|
|
120
|
+
writeFileSync(file, await stage.capture());
|
|
121
|
+
return file;
|
|
122
|
+
} finally {
|
|
123
|
+
await stage.close();
|
|
124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Adds a file the renderer wrote after the stills set to `stills/stills.json`; returns the new index SHA-256.
|
|
129
|
+
export function addToStills(out, entry) {
|
|
130
|
+
const indexFile = path.join(out, "stills", "stills.json");
|
|
131
|
+
const index = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
132
|
+
const file = path.join(out, entry.file);
|
|
133
|
+
if (!existsSync(file)) throw new Error(`addToStills: ${entry.file} was not written`);
|
|
134
|
+
index.files = [...index.files.filter((f) => f.file !== entry.file), { ...entry, sha256: sha256(readFileSync(file)) }];
|
|
135
|
+
const bytes = Buffer.from(`${JSON.stringify(index, null, 2)}\n`);
|
|
136
|
+
writeFileSync(indexFile, bytes);
|
|
137
|
+
return sha256(bytes);
|
|
138
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The cover's asset list, `<out>/assets.json`. Every raster the cover page can load (base art, logo,
|
|
2
|
-
// wordmark, texture) is listed with its path, role, source (user: supplied;
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// wordmark, texture) is listed with its path, role, source (user: supplied; fetched: taken from the
|
|
3
|
+
// product's own pages by `render.mjs assets --cover`; generated: made for this cover and labelled so
|
|
4
|
+
// in the reply) and SHA-256, and the renderer checks the list before the first capture. The art is
|
|
5
|
+
// supplied, fetched or generated, never replaced by a drawn stand-in: a listed file that is missing
|
|
6
|
+
// stops the run as BLOCKED_ASSET_MISSING (exit 22), and so does the painted-light recipe with no
|
|
7
|
+
// user-supplied base art listed. After the render, decoded frame 0 must show the base art (artCheck).
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
8
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
@@ -35,7 +36,7 @@ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
|
|
|
35
36
|
if (!entry || typeof entry !== "object") throw badEntry(at, "must be an object");
|
|
36
37
|
if (typeof entry.path !== "string" || !entry.path.startsWith(`${coverDir}/`) || entry.path.split("/").includes("..")) throw badEntry(`${at}.path`, `must be a path inside ${coverDir}/, relative to the output directory`);
|
|
37
38
|
if (!ASSET_ROLES.includes(entry.role)) throw badEntry(`${at}.role`, `must be one of ${ASSET_ROLES.join(", ")}`);
|
|
38
|
-
if (!["user", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied) or generated");
|
|
39
|
+
if (!["user", "fetched", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied), fetched (by render.mjs assets --cover) or generated");
|
|
39
40
|
if (!/^[0-9a-f]{64}$/u.test(entry.sha256 ?? "")) throw badEntry(`${at}.sha256`, "must be the file's SHA-256 (64 hex digits)");
|
|
40
41
|
if ("box" in entry && !isBox(entry.box)) throw badEntry(`${at}.box`, "must be [x, y, w, h] as fractions of the cover, inside it");
|
|
41
42
|
if (entry.role === "base-art" && !entry.box) throw badEntry(`${at}.box`, "base art needs the box it fills on the cover");
|
|
@@ -52,8 +53,10 @@ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
|
|
|
52
53
|
if (sha256(readFileSync(real)) !== asset.sha256) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${asset.path} no longer matches its sha256 in assets.json; list it again after changing it`);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
// The painted recipe relights a painting the user supplied. Without one the cover takes hero-3d
|
|
57
|
+
// (or weight-tide, orbit-loop) instead of a stand-in painting drawn for the occasion.
|
|
58
|
+
if (treatment.recipe === "painted-light" && !assets.some((asset) => asset.role === "base-art" && asset.source === "user")) {
|
|
59
|
+
throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", "the painted-light recipe relights a painting, and assets.json lists no base-art with source user: a painting the user supplied; without one, take hero-3d (or weight-tide, orbit-loop), never draw or generate a stand-in");
|
|
57
60
|
}
|
|
58
61
|
const listed = new Set(assets.map((asset) => asset.path));
|
|
59
62
|
for (const raster of rasters) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// One cover browser: the stage path's software rung and stage flags at device scale 2, the cover
|
|
1
|
+
// One cover browser: the stage path's software rung (the GPU rung for a 3D hero) and stage flags at device scale 2, the cover
|
|
2
2
|
// page served from disk on the stage origin (engine/stage/capture.mjs serveStage, so no socket
|
|
3
3
|
// listens), the loop kit at /lit/loop-kit.js, the product faces plus JetBrains Mono at
|
|
4
4
|
// /lit/fonts.css, and the cover init script before any page script. The renderer SEEKS: it calls
|
|
@@ -23,9 +23,9 @@ const stageDir = path.join(here, "..", "stage");
|
|
|
23
23
|
* software GL (canvas fillText, and DOM text at changing weights) came out one level apart on a
|
|
24
24
|
* handful of pixels between them; CPU raster gives the same pixels every time.
|
|
25
25
|
*/
|
|
26
|
-
export function coverChromeFlags(width, height, scale) {
|
|
27
|
-
const flags = stageChromeFlags(width, height).map((flag) => (flag === "--force-device-scale-factor=1" ? `--force-device-scale-factor=${scale}` : flag));
|
|
28
|
-
return [...flags, "--disable-gpu-rasterization", "--disable-partial-raster"];
|
|
26
|
+
export function coverChromeFlags(width, height, scale, rung = "swiftshader") {
|
|
27
|
+
const flags = stageChromeFlags(width, height, rung).map((flag) => (flag === "--force-device-scale-factor=1" ? `--force-device-scale-factor=${scale}` : flag));
|
|
28
|
+
return [...new Set([...flags, "--disable-gpu-rasterization", "--disable-partial-raster"])];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The stage faces plus JetBrains Mono, a variable face with a real wght axis (100-800). */
|
|
@@ -60,8 +60,8 @@ export async function waitForCoverReady(tab, pageFailed, timeout = 60000) {
|
|
|
60
60
|
* Launch a cover browser on `coverDir` and load its index.html. `page` is the CSS size; screenshots
|
|
61
61
|
* are `scale` times that. Returns { frame(t, { mask }), capture(), close(), flags }.
|
|
62
62
|
*/
|
|
63
|
-
export async function openCover(chromium, { chromePath, profile, coverDir, page: [width, height], scale, fonts }) {
|
|
64
|
-
const flags = coverChromeFlags(width, height, scale);
|
|
63
|
+
export async function openCover(chromium, { chromePath, profile, coverDir, page: [width, height], scale, fonts, rung = "swiftshader" }) {
|
|
64
|
+
const flags = coverChromeFlags(width, height, scale, rung);
|
|
65
65
|
let context;
|
|
66
66
|
try {
|
|
67
67
|
context = await chromium.launchPersistentContext(profile, {
|
|
@@ -114,14 +114,27 @@ function prepare(out) {
|
|
|
114
114
|
if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
|
|
115
115
|
const chrome = findChrome();
|
|
116
116
|
if (!chrome.path) throw stop(EXIT.BLOCKED_NO_CHROME, "BLOCKED_NO_CHROME", `${chrome.missing}; fix: install Chrome or set CHROME_PATH`);
|
|
117
|
-
return { cover, coverDir, assets, state, chromePath: chrome.path, fonts: coverFonts(skillRoot, state, pins) };
|
|
117
|
+
return { cover, coverDir, assets, state, chromePath: chrome.path, fonts: coverFonts(skillRoot, state, pins), rung: cover.recipe === "hero-3d" ? "gpu" : "swiftshader", notices: [] };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
// One browser for one pass; the profile lives under .run/cover/ and goes with the pass.
|
|
120
|
+
// One browser for one pass; the profile lives under .run/cover/ and goes with the pass. The 3D hero asks
|
|
121
|
+
// for the GPU rung. When a browser cannot start or draw there, every pass of the round takes the
|
|
122
|
+
// software rung (the passes must share one) and the report says so; a page that breaks the
|
|
123
|
+
// determinism or network rules is not retried, it would break the same way.
|
|
121
124
|
async function withBrowser(chromium, context, profile, body) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
const open = async () => {
|
|
126
|
+
rmSync(profile, { recursive: true, force: true });
|
|
127
|
+
mkdirSync(profile, { recursive: true });
|
|
128
|
+
return openCover(chromium, { chromePath: context.chromePath, profile, coverDir: context.coverDir, page: context.cover.page, scale: COVER.captureScale, fonts: context.fonts, rung: context.rung });
|
|
129
|
+
};
|
|
130
|
+
let browser;
|
|
131
|
+
try { browser = await open(); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (context.rung !== "gpu" || [EXIT.STAGE_NETWORK_REQUEST, EXIT.STAGE_NONDETERMINISTIC].includes(error.exitCode)) throw error;
|
|
134
|
+
context.notices.push(`the 3D hero asked for the GPU rung and it failed there (${String(error.message).slice(0, 120)}); rendered on the software rung (SwiftShader), slower, same frames`);
|
|
135
|
+
context.rung = "swiftshader";
|
|
136
|
+
browser = await open();
|
|
137
|
+
}
|
|
125
138
|
try { return await body(browser); } finally { await browser.close(); rmSync(profile, { recursive: true, force: true }); }
|
|
126
139
|
}
|
|
127
140
|
|
|
@@ -255,11 +268,11 @@ export async function renderCover(out, { round = 1, log = () => {} } = {}) {
|
|
|
255
268
|
renameSync(encoded.file, delivered);
|
|
256
269
|
const stills = writeCoverStills(out, { round, gate, cover });
|
|
257
270
|
writeFileSync(path.join(out, "poster.png"), readFileSync(path.join(out, "stills", "poster.png")));
|
|
258
|
-
const report = formatCoverReport(gate, { strict: true })
|
|
271
|
+
const report = `${formatCoverReport(gate, { strict: true })}${context.notices.map((note) => `warning: ${note}\n`).join("")}`;
|
|
259
272
|
writeFileSync(path.join(out, "gate-report.txt"), report);
|
|
260
273
|
const manifest = {
|
|
261
274
|
schemaVersion: 1, mode: "cover", path: "cover", craftRound: round, recipe: cover.recipe,
|
|
262
|
-
page: cover.page, size, scale: COVER.captureScale, fps: cover.fps, frames: times.length, frameMs: ms, seed: cover.seed,
|
|
275
|
+
page: cover.page, size, scale: COVER.captureScale, rung: context.rung, warnings: context.notices, fps: cover.fps, frames: times.length, frameMs: ms, seed: cover.seed,
|
|
263
276
|
encoder: encoded.encoder, encode: cover.encode, file: path.basename(delivered), bytes: statSync(delivered).size, captureSec: Number(captureSec.toFixed(1)),
|
|
264
277
|
treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), coverSha256: stageSha(context.coverDir), stillsSha256: stills.sha256,
|
|
265
278
|
gate: { pass: gate.pass, target: gate.target, medianD: gate.medianD, movingFrac: gate.movingFrac, longestStillSec: gate.longestStillSec, spike: gate.spike, seamLocal: gate.seamLocal, gates: gate.gates },
|