lecodes-cli 0.20.2 → 2.0.0
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 +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
package/src/commands/render.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { dirname, resolve } from "node:path"
|
|
3
3
|
import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
4
|
-
import {
|
|
5
|
-
import { findProjectRoot } from "../manifest"
|
|
4
|
+
import { screenTargetFrom } from "../compile/screenEntry"
|
|
5
|
+
import { findProjectRoot } from "../project/manifest"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
|
-
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./
|
|
8
|
-
import { runDesktopRender } from "../desktopRenderer"
|
|
9
|
-
import { evaluateDesktopRun, translateScenario } from "../desktopScript"
|
|
10
|
-
import { CliError,
|
|
11
|
-
import {
|
|
7
|
+
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
8
|
+
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
9
|
+
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
10
|
+
import { CliError, bool, c, defineCommand, isNullSink, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
|
|
11
|
+
import { bundleFlags, bundleOptions, outFlag } from "./shared"
|
|
12
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
12
13
|
|
|
13
14
|
/*
|
|
14
15
|
* `lecodes render` — compile the local project and render it headless. By default it emits a
|
|
15
16
|
* semantic JSON description (stdout or -o/--out); `--png [file]` also rasterizes a PNG. Rendering
|
|
16
|
-
* runs locally via the optional `lecodes-
|
|
17
|
+
* runs locally via the optional `lecodes-headless` package (canvas-ui + the layout WASM, and the
|
|
17
18
|
* GPU-free creator-2d WASM for 2D scenes); PNG also needs @napi-rs/canvas. UI and 2D (creator-2d)
|
|
18
19
|
* scenes are both rendered — the 2D scene composites UNDER the UI. 3D (Filament) isn't rasterized,
|
|
19
20
|
* but the scene graph is recorded and serialized into the JSON output as `scene3d` (hierarchy,
|
|
@@ -29,32 +30,49 @@ import { loadPeer } from "../peerInstall"
|
|
|
29
30
|
*
|
|
30
31
|
* `--script <file>` switches to an interactive run: the file lists steps (tap / type / scroll /
|
|
31
32
|
* wait / expect / screenshot / json) executed against a live session — see the scenario schema in
|
|
32
|
-
* lecodes-
|
|
33
|
+
* lecodes-headless (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
|
|
33
34
|
* the run stops at the first failed step and dumps the failing frame next to them.
|
|
34
35
|
*
|
|
36
|
+
* `--desktop` renders through the NATIVE desktop host instead of the headless renderer — the real
|
|
37
|
+
* tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG only
|
|
38
|
+
* (the host has no semantic-JSON serializer), no lecodes-headless / @napi-rs/canvas needed. The
|
|
39
|
+
* host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now.
|
|
40
|
+
*
|
|
35
41
|
* Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
|
|
36
42
|
* compile/headlessBundle.ts. Resource assets (asset('./x.png')) resolve from the local working tree
|
|
37
43
|
* by default; use --remote-assets (or an explicit --public-url) to load them from the server
|
|
38
44
|
* instead, which then needs --wait-network.
|
|
39
45
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* --time <ms> render at this animation time (0 = the initial frame-0 state)
|
|
45
|
-
* --no-after-animations keep frame 0 instead of advancing animations to their resting state
|
|
46
|
-
* --timeout <ms> hard cap on the whole render before aborting (default 15000; a script
|
|
47
|
-
* run defaults to 15000 + 3000 per step)
|
|
48
|
-
* --safe-area t,r,b,l override the safe-area insets (px) the `safe-*` keywords resolve to
|
|
49
|
-
* --device <preset> safe-area preset: none | iphone | android (default: orientation-based).
|
|
50
|
-
* Presets also carry the edge KIND: android's status/nav bars are exact
|
|
51
|
-
* heights (comfort-* adds its knob past them); iphone insets are
|
|
52
|
-
* clearance zones (comfort-* floors). --safe-area alone = all clearance.
|
|
53
|
-
* --logs forward the app's console.log/error (to stderr) for logic debugging
|
|
54
|
-
* --clip <name|x,y,w,h> (--png) crop to a named node's layout rect or an explicit logical-px
|
|
55
|
-
* rect — a component / region render instead of the whole viewport
|
|
46
|
+
* The viewport / timing flags and the --desktop flags are exported as groups because `lecodes test`
|
|
47
|
+
* takes the very same ones. The device presets carry the edge KIND: android's status/nav bars are
|
|
48
|
+
* exact heights (comfort-* adds its knob past them); iphone insets are clearance zones (comfort-*
|
|
49
|
+
* floors). --safe-area alone = all clearance.
|
|
56
50
|
*/
|
|
57
51
|
|
|
52
|
+
// ---- flag groups (shared with `lecodes test`) ------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** Viewport + snapshot timing. */
|
|
55
|
+
export const viewportFlags = {
|
|
56
|
+
width: num("viewport width, logical px (default 390 — a desktop-only project renders at its window, a design screen at its board frame)"),
|
|
57
|
+
height: num("viewport height, logical px (default 844, same fallbacks)"),
|
|
58
|
+
settle: num("max wait for promises / timers / fetch / image decode before the snapshot (ends early when idle)", { value: "<ms>" }),
|
|
59
|
+
"wait-network": bool("let data fetches hit the real network (off by default → empty responses)"),
|
|
60
|
+
device: str("safe-area preset: none | iphone | android (default: orientation-based)", { value: "<preset>" }),
|
|
61
|
+
"safe-area": str("override the safe-area insets (px) the safe-* keywords resolve to", { value: "<t,r,b,l>" }),
|
|
62
|
+
logs: bool("forward the app's console output (to stderr)"),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `--desktop`: the NATIVE host in place of the headless renderer. */
|
|
66
|
+
export const desktopRenderFlags = {
|
|
67
|
+
desktop: bool("render through the NATIVE desktop host instead: real Filament 3D + tgfx UI + 2D, composited. PNG only (--png). Downloads the LeCodes Desktop renderer on first use into ~/.lecodes/renderer (Windows/Linux; LECODES_DESKTOP_EXE overrides)"),
|
|
68
|
+
sim: bool("run a --script scenario through the WINDOWLESS native host (lecodes-server, beside the desktop host; LECODES_SERVER_EXE overrides): the real engine — Jolt, animators, navmesh — with no GPU and no pixels, so it runs on a CI box or a container and in parallel with an open desktop window. expect.log / expect.paint only; input is scene input (key/look/stick/drag/tap by { x, y } straight to the scene — no UI, so no names, hover or type); no screenshots, no --png"),
|
|
69
|
+
frames: num("(--desktop) frames to run before the shot — headroom for async loads", { default: 60 }),
|
|
70
|
+
"fixed-dt": num("(--desktop) virtual app clock, ms per frame — deterministic game time (scripted runs default to 16; plain renders to wall clock)", { value: "<ms>" }),
|
|
71
|
+
renderer: str("(--desktop) host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- helpers `lecodes test` shares -----------------------------------------------------------
|
|
75
|
+
|
|
58
76
|
// Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
|
|
59
77
|
// edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
|
|
60
78
|
// inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
|
|
@@ -83,8 +101,8 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
|
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
/** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
|
|
86
|
-
export const requireRenderer = async (): Promise<typeof import("lecodes-
|
|
87
|
-
loadPeer<typeof import("lecodes-
|
|
104
|
+
export const requireRenderer = async (): Promise<typeof import("lecodes-headless/headless")> =>
|
|
105
|
+
loadPeer<typeof import("lecodes-headless/headless")>("lecodes-headless", "headless", { for: "lecodes render" })
|
|
88
106
|
|
|
89
107
|
/** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
|
|
90
108
|
export const fileScenarioIO = (dir: string) => ({
|
|
@@ -113,7 +131,7 @@ type ScenarioFile = {
|
|
|
113
131
|
export const resolveFixtures = (
|
|
114
132
|
value: Record<string, unknown> | string | undefined,
|
|
115
133
|
baseDir: string,
|
|
116
|
-
): import("lecodes-
|
|
134
|
+
): import("lecodes-headless/headless").Fixtures | undefined => {
|
|
117
135
|
if (value === undefined) return undefined
|
|
118
136
|
let map: unknown = value
|
|
119
137
|
if (typeof value === "string") {
|
|
@@ -127,7 +145,7 @@ export const resolveFixtures = (
|
|
|
127
145
|
if (typeof map !== "object" || map === null || Array.isArray(map)) {
|
|
128
146
|
throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`)
|
|
129
147
|
}
|
|
130
|
-
return map as import("lecodes-
|
|
148
|
+
return map as import("lecodes-headless/headless").Fixtures
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
const readScenarioFile = (path: string): ScenarioFile => {
|
|
@@ -157,11 +175,82 @@ const parseClip = (raw: string | undefined): string | { x: number, y: number, wi
|
|
|
157
175
|
throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
178
|
+
/** `--png` / `--map` take an optional file: bare = the default name, absent = off. */
|
|
179
|
+
const optionalFile = (value: string | undefined, fallback: string): string | undefined =>
|
|
180
|
+
value === undefined ? undefined : value || fallback
|
|
181
|
+
|
|
182
|
+
// ---- the command -----------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const renderFlags = {
|
|
185
|
+
state: str("([file]) the state to pass a (state) => UIScreen screen", { value: "<name>" }),
|
|
186
|
+
dir: str("([file]) the design folder a bare screen id resolves in (default design/)", { value: "<folder>" }),
|
|
187
|
+
...outFlag,
|
|
188
|
+
png: str("rasterize a PNG (default screenshot.png; needs @napi-rs/canvas)", { value: "[file]", optional: true }),
|
|
189
|
+
map: str("draw the 3D scene's annotated top-down map (grid + XZ footprints + named markers) as <file>.png + <file>.json world-space data (default map.png; also a { \"map\": \"f.png\" } script step)", { value: "[file]", optional: true }),
|
|
190
|
+
"label-all": bool("(--map) label every object, not only the named markers"),
|
|
191
|
+
"max-px": num("(--map) longest edge of the map image, px"),
|
|
192
|
+
script: str("drive the app through a step list (tap/type/fill/scroll/hover/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop the same steps, targets (a name or { x, y }) and expects run on the native host — only expect.audio and the json output are headless-only; per-step screenshots + final --png", { value: "<file>" }),
|
|
193
|
+
"out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
|
|
194
|
+
clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
|
|
195
|
+
fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
|
|
196
|
+
scale: num("PNG device-pixel scale", { default: 2 }),
|
|
197
|
+
time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
|
|
198
|
+
"no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
|
|
199
|
+
timeout: num("hard cap on the whole render before aborting (default 15000, + 3000 per script step; --desktop 60000, + 5000 per step)", { value: "<ms>" }),
|
|
200
|
+
...viewportFlags,
|
|
201
|
+
...bundleFlags,
|
|
202
|
+
...desktopRenderFlags,
|
|
203
|
+
view: str("(--desktop) inspection camera auto-fitted to the whole 3D scene: iso | top | front | side | back [--fov N]; the app's camera is untouched", { value: "<name>" }),
|
|
204
|
+
camera: str("(--desktop) inspection camera at a fixed eye point (world units, Y up) — goes with --look-at [--fov N]. In a --script run use { \"view\" } / { \"camera\" } steps", { value: "<x,y,z>" }),
|
|
205
|
+
"look-at": str("(--desktop) the point --camera looks at", { value: "<x,y,z>" }),
|
|
206
|
+
fov: num("(--desktop) vertical field of view for --view / --camera, degrees", { value: "<deg>" }),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type RenderFlags = FlagValues<typeof renderFlags>
|
|
210
|
+
|
|
211
|
+
export default defineCommand({
|
|
212
|
+
name: "render",
|
|
213
|
+
summary: "Render the local project headless (JSON / PNG), or through the desktop host",
|
|
214
|
+
usage: "[file]",
|
|
215
|
+
description: "Needs the optional lecodes-headless package (installed on first use). [file] renders ONE screen module instead of the app: a file that default-exports a UIScreen (or a (state) => UIScreen) — every `lecodes design` screen is written that way. A bare id (\"login\") resolves in design/screens/, and a design screen renders at its meta.json frame.\n\nasset() resources load from the working tree by default; --remote-assets / --public-url use the server's copies (then data needs --wait-network).",
|
|
216
|
+
flags: renderFlags,
|
|
217
|
+
examples: [
|
|
218
|
+
"lecodes render -o ui.json",
|
|
219
|
+
"lecodes render --png shots/home.png --device iphone",
|
|
220
|
+
"lecodes render login --state empty --png",
|
|
221
|
+
"lecodes render --script tests/hero.flow.json --out-dir tests/.shots --logs",
|
|
222
|
+
"lecodes render --desktop --png --view iso",
|
|
223
|
+
],
|
|
224
|
+
run: ({ args, flags }) => flags.desktop || flags.sim ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// ---- --desktop -------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
230
|
+
const parseVec3Flag = (name: string, raw: string): string => {
|
|
231
|
+
const parts = raw.split(",").map((p) => p.trim())
|
|
232
|
+
if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
|
|
233
|
+
return parts.join(",")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
|
|
237
|
+
const resolveInspectionCamera = ({ camera: cam, "look-at": look, view, fov }: Pick<RenderFlags, "camera" | "look-at" | "view" | "fov">): { camera?: string, view?: string } => {
|
|
238
|
+
if (fov !== undefined && !(fov > 0 && fov < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
|
|
239
|
+
const fovSuffix = fov !== undefined ? `;${fov}` : ""
|
|
240
|
+
if (cam !== undefined || look !== undefined) {
|
|
241
|
+
if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
|
|
242
|
+
if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
|
|
243
|
+
return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
|
|
244
|
+
}
|
|
245
|
+
if (view !== undefined) {
|
|
246
|
+
if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
|
|
247
|
+
return { view: `${view}${fovSuffix}` }
|
|
248
|
+
}
|
|
249
|
+
if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
|
|
250
|
+
return {}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const renderDesktop = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
|
|
165
254
|
// A project with a desktop block renders at its configured window (this IS the desktop host);
|
|
166
255
|
// the phone-portrait literal stays only for projects that never declared one.
|
|
167
256
|
let root: string
|
|
@@ -169,48 +258,51 @@ const renderDesktop = async (args: Args) => {
|
|
|
169
258
|
const win = readAppConfigAt(root)?.desktop?.window
|
|
170
259
|
// `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
|
|
171
260
|
// runs the same bundle, so a design screen renders through the real tgfx UI here.
|
|
172
|
-
const screen =
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
const
|
|
261
|
+
const screen = screenTargetFrom(root, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
|
|
262
|
+
const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
|
|
263
|
+
// --sim: the windowless host — a scripted run judged by its log; nothing to render without a script.
|
|
264
|
+
const sim = flags.sim
|
|
265
|
+
if (sim && !scenario) throw new CliError("--sim runs a --script scenario (the windowless host paints nothing to render) — add --script <file>, or use --desktop for a PNG.")
|
|
266
|
+
if (sim && flags.png) throw new CliError("--sim produces no PNG (the windowless host paints nothing) — drop --png, or use --desktop.")
|
|
267
|
+
const width = flags.width || scenario?.width || screen?.frame?.[0] || win?.width || 390
|
|
268
|
+
const height = flags.height || scenario?.height || screen?.frame?.[1] || win?.height || 844
|
|
269
|
+
const frames = flags.frames || 60
|
|
270
|
+
const pngFlag = flags.png || "screenshot.png"
|
|
271
|
+
const logs = flags.logs
|
|
180
272
|
// --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
|
|
181
273
|
// key / drag / wait means the same game time on every machine; plain renders keep the wall clock
|
|
182
274
|
// (their contract is "frame N", unchanged) unless asked.
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
|
|
275
|
+
const fixedDtMs = flags["fixed-dt"] ?? (scenario ? 16 : 0)
|
|
276
|
+
if (fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtMs}".`)
|
|
186
277
|
|
|
187
278
|
// Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
|
|
188
279
|
// [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
|
|
189
280
|
// scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
|
|
190
|
-
const { camera, view } = resolveInspectionCamera(
|
|
281
|
+
const { camera, view } = resolveInspectionCamera(flags)
|
|
191
282
|
if ((camera || view) && scenario) throw new CliError("--camera/--view apply to a plain render; in a --script run use a { \"view\": \"iso\" } or { \"camera\": { \"at\", \"lookAt\" } } step before the screenshot.")
|
|
192
283
|
|
|
193
|
-
// One renderer decision: the host binary AND the staged .filamat variant (see
|
|
194
|
-
const desktopRenderer = resolveDesktopRenderer(root,
|
|
284
|
+
// One renderer decision: the host binary AND the staged .filamat variant (see app/shared).
|
|
285
|
+
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
195
286
|
// Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
|
|
196
287
|
const { dirs: pluginDirs } = desktopPluginLibraries(root)
|
|
197
|
-
const { js, resources } = await buildBundle(
|
|
288
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
198
289
|
localAssets: true, desktopRenderer,
|
|
199
290
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
200
291
|
})
|
|
201
292
|
// The host writes PNGs but never creates folders — make every output dir up front.
|
|
202
|
-
mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
|
|
293
|
+
if (!sim) mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
|
|
203
294
|
|
|
204
295
|
if (scenario) {
|
|
205
296
|
// Scripted native run: translate the scenario into the host's command file, let the host drive
|
|
206
297
|
// it (it self-terminates), then read its markers back into per-step results.
|
|
207
|
-
const outDir =
|
|
208
|
-
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
|
|
298
|
+
const outDir = flags["out-dir"]
|
|
299
|
+
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames, sim })
|
|
209
300
|
for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
|
|
210
|
-
const timeoutMs =
|
|
211
|
-
|
|
301
|
+
const timeoutMs = flags.timeout || (60000 + plan.steps.length * 5000)
|
|
302
|
+
const label = sim ? "sim" : "native"
|
|
303
|
+
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (${label})…`)
|
|
212
304
|
const { output } = await runDesktopRender(js, resources, {
|
|
213
|
-
renderer: desktopRenderer, pluginDirs,
|
|
305
|
+
renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
|
|
214
306
|
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
215
307
|
})
|
|
216
308
|
const result = evaluateDesktopRun(plan, output)
|
|
@@ -224,12 +316,12 @@ const renderDesktop = async (args: Args) => {
|
|
|
224
316
|
const skippedCount = result.steps.reduce((n, s) => n + s.skipped.length, 0)
|
|
225
317
|
if (skippedCount > 0) logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`))
|
|
226
318
|
if (!result.ended) warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.")
|
|
227
|
-
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (
|
|
228
|
-
note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
|
|
319
|
+
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (${label})`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (${label})`))
|
|
320
|
+
if (!sim) note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
|
|
229
321
|
process.exit(result.ok ? 0 : 1)
|
|
230
322
|
}
|
|
231
323
|
|
|
232
|
-
const timeoutMs =
|
|
324
|
+
const timeoutMs = flags.timeout || 60000
|
|
233
325
|
note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
|
|
234
326
|
await runDesktopRender(js, resources, {
|
|
235
327
|
renderer: desktopRenderer, pluginDirs,
|
|
@@ -238,72 +330,45 @@ const renderDesktop = async (args: Args) => {
|
|
|
238
330
|
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
|
239
331
|
}
|
|
240
332
|
|
|
241
|
-
|
|
242
|
-
const parseVec3Flag = (name: string, raw: string): string => {
|
|
243
|
-
const parts = raw.split(",").map((p) => p.trim())
|
|
244
|
-
if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
|
|
245
|
-
return parts.join(",")
|
|
246
|
-
}
|
|
247
|
-
/** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
|
|
248
|
-
const resolveInspectionCamera = (args: Args): { camera?: string, view?: string } => {
|
|
249
|
-
const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov")
|
|
250
|
-
if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
|
|
251
|
-
const fovSuffix = fov !== undefined ? `;${Number(fov)}` : ""
|
|
252
|
-
if (cam !== undefined || look !== undefined) {
|
|
253
|
-
if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
|
|
254
|
-
if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
|
|
255
|
-
return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
|
|
256
|
-
}
|
|
257
|
-
if (view !== undefined) {
|
|
258
|
-
if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
|
|
259
|
-
return { view: `${view}${fovSuffix}` }
|
|
260
|
-
}
|
|
261
|
-
if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
|
|
262
|
-
return {}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export const render = async (args: Args) => {
|
|
266
|
-
if (flagBool(args, "desktop")) return renderDesktop(args)
|
|
333
|
+
// ---- headless --------------------------------------------------------------------------------
|
|
267
334
|
|
|
268
|
-
|
|
269
|
-
const scenario =
|
|
335
|
+
const renderHeadless = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
|
|
336
|
+
const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
|
|
270
337
|
|
|
271
338
|
// `render <file>`: render that one screen module instead of the app's own entrypoint.
|
|
272
339
|
let projectRoot: string
|
|
273
340
|
try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
|
|
274
|
-
const screen =
|
|
341
|
+
const screen = screenTargetFrom(projectRoot, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
|
|
275
342
|
|
|
276
343
|
// Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
|
|
277
344
|
// a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
|
|
278
345
|
const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
|
|
279
|
-
const timeoutMs =
|
|
346
|
+
const timeoutMs = flags.timeout || (15000 + stepCount * 3000)
|
|
280
347
|
const killer = setTimeout(() => {
|
|
281
348
|
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
|
|
282
349
|
process.exit(1)
|
|
283
350
|
}, timeoutMs)
|
|
284
351
|
killer.unref?.()
|
|
285
352
|
|
|
286
|
-
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(
|
|
353
|
+
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
287
354
|
const renderer = await requireRenderer()
|
|
288
355
|
|
|
289
356
|
// Flags override the scenario file's envelope; both fall back to the project default (a
|
|
290
357
|
// desktop-only app.json renders desktop-sized), then the phone-portrait literals.
|
|
291
358
|
// A design screen carries its own board frame (meta.json), which beats the app-wide default.
|
|
292
359
|
const projectViewport = desktopDefaultViewport(root)
|
|
293
|
-
const width =
|
|
294
|
-
const height =
|
|
295
|
-
|
|
296
|
-
// Shared snapshot-timing options
|
|
297
|
-
//
|
|
298
|
-
const
|
|
299
|
-
const timeStr = flagStr(args, "time")
|
|
300
|
-
const onConsole = flagBool(args, "logs")
|
|
360
|
+
const width = flags.width || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
|
|
361
|
+
const height = flags.height || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
|
|
362
|
+
|
|
363
|
+
// Shared snapshot-timing options. `--logs` forwards the app's console to stderr (kept off stdout
|
|
364
|
+
// so it never corrupts the JSON/PNG result there).
|
|
365
|
+
const onConsole = flags.logs
|
|
301
366
|
? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
|
|
302
367
|
: undefined
|
|
303
368
|
// Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
|
|
304
369
|
// over it (flag patterns win on collision). Validated here so a typo is a clean CLI error.
|
|
305
|
-
const envelopeFixtures = resolveFixtures(scenario?.fixtures,
|
|
306
|
-
const flagFixtures = resolveFixtures(
|
|
370
|
+
const envelopeFixtures = resolveFixtures(scenario?.fixtures, flags.script !== undefined ? dirname(resolve(flags.script)) : ".")
|
|
371
|
+
const flagFixtures = resolveFixtures(flags.fixtures, ".")
|
|
307
372
|
const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
|
|
308
373
|
if (fixtures) {
|
|
309
374
|
try {
|
|
@@ -314,13 +379,13 @@ export const render = async (args: Args) => {
|
|
|
314
379
|
}
|
|
315
380
|
|
|
316
381
|
const timing = {
|
|
317
|
-
settleMs:
|
|
318
|
-
waitNetwork:
|
|
382
|
+
settleMs: flags.settle ?? scenario?.settle,
|
|
383
|
+
waitNetwork: flags["wait-network"],
|
|
319
384
|
localAssets: useLocalAssets,
|
|
320
385
|
fixtures,
|
|
321
|
-
timeMs:
|
|
322
|
-
afterAnimations: !
|
|
323
|
-
...resolveSafeAreaChoice(
|
|
386
|
+
timeMs: flags.time,
|
|
387
|
+
afterAnimations: !flags["no-after-animations"],
|
|
388
|
+
...resolveSafeAreaChoice(flags["safe-area"] ?? scenario?.safeArea, flags.device ?? scenario?.device),
|
|
324
389
|
onConsole,
|
|
325
390
|
}
|
|
326
391
|
|
|
@@ -331,7 +396,7 @@ export const render = async (args: Args) => {
|
|
|
331
396
|
// --script: drive the compiled app through the scenario steps against a live session.
|
|
332
397
|
if (scenario) {
|
|
333
398
|
const steps = renderer.parseScenarioSteps(scenario.steps)
|
|
334
|
-
const outDir =
|
|
399
|
+
const outDir = flags["out-dir"]
|
|
335
400
|
// Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
|
|
336
401
|
// PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
|
|
337
402
|
const needsRaster = steps.some((s) => s.screenshot !== undefined)
|
|
@@ -356,12 +421,12 @@ export const render = async (args: Args) => {
|
|
|
356
421
|
// --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
|
|
357
422
|
// markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
|
|
358
423
|
// (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
|
|
359
|
-
const mapFlag =
|
|
424
|
+
const mapFlag = optionalFile(flags.map, "map.png")
|
|
360
425
|
if (mapFlag !== undefined) {
|
|
361
426
|
note(`Rendering map…`)
|
|
362
427
|
const title = root.split(/[\\/]/).pop()
|
|
363
428
|
const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
|
|
364
|
-
...timing, maxPx:
|
|
429
|
+
...timing, maxPx: flags["max-px"] || undefined, title, labelAll: flags["label-all"],
|
|
365
430
|
})
|
|
366
431
|
for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
|
|
367
432
|
const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
|
|
@@ -374,12 +439,12 @@ export const render = async (args: Args) => {
|
|
|
374
439
|
}
|
|
375
440
|
|
|
376
441
|
// --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
|
|
377
|
-
const pngFlag =
|
|
442
|
+
const pngFlag = optionalFile(flags.png, "screenshot.png")
|
|
378
443
|
if (pngFlag !== undefined) {
|
|
379
444
|
note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
|
|
380
|
-
const clip = parseClip(
|
|
445
|
+
const clip = parseClip(flags.clip)
|
|
381
446
|
const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
|
|
382
|
-
width, height, scale:
|
|
447
|
+
width, height, scale: flags.scale || 2, ...timing, clip,
|
|
383
448
|
})
|
|
384
449
|
for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
|
|
385
450
|
if (isNullSink(pngFlag)) {
|
|
@@ -397,7 +462,7 @@ export const render = async (args: Args) => {
|
|
|
397
462
|
|
|
398
463
|
for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
|
|
399
464
|
|
|
400
|
-
const out =
|
|
465
|
+
const out = flags.out
|
|
401
466
|
if (out && isNullSink(out)) {
|
|
402
467
|
note(`Discarded output (${out})`)
|
|
403
468
|
return done((cb) => cb())
|