lecodes-cli 0.20.1 → 1.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 +5920 -5022
- package/package.json +9 -7
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/materials/particles-quad.mat +17 -9
- package/runtime/materials/particles.mat +17 -9
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +2 -2
- 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 +133 -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} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- 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 +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- 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 +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- 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 +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -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/{projectEnv.ts → project/env.ts} +33 -2
- 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/web/assets/createViewerLite-C_bkKXLg.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.ts} +0 -0
package/src/commands/push.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { basename } from "node:path"
|
|
2
1
|
import { readFileSync } from "node:fs"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
2
|
+
import { basename } from "node:path"
|
|
3
|
+
import { CliError, bool, c, confirm, defineCommand, info, log, prompt, str, success, warn } from "../cli"
|
|
4
|
+
import { getCommits, getProject, publishProject, sync, uploadBlob, type Project, type SyncFileInput, type SyncFolderInput } from "../platform/api"
|
|
5
|
+
import { computeServerDiff } from "../platform/serverDiff"
|
|
6
|
+
import { classifyKind, diffLocal, hasChanges, isTextKind, scanProject } from "../project/localFiles"
|
|
7
|
+
import { writeManifest, type FileKind, type Manifest, type ManifestFile } from "../project/manifest"
|
|
8
|
+
import { buildPaths } from "../project/materialize"
|
|
9
|
+
import { linkedProject } from "./shared"
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
* `lecodes push -m "message"` — diff the working tree against the manifest, upload changed binaries,
|
|
@@ -21,33 +21,42 @@ import { CliError, c, confirm, flagBool, flagStr, info, log, prompt, success, wa
|
|
|
21
21
|
* arrives before the fix is a lie the reviewer has no way to detect. That ordering is why nothing
|
|
22
22
|
* else in the toolchain sends a comment upward.
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
|
|
25
|
+
export const pushFlags = {
|
|
26
|
+
message: str("checkpoint message (asked for when omitted)", { alias: "m", value: "<text>" }),
|
|
27
|
+
publish: bool("also deploy the project live (compile) with the push"),
|
|
28
|
+
force: bool("push even if the server moved on (shows + confirms which server files get overwritten)"),
|
|
29
|
+
yes: bool("skip the --force overwrite confirmation", { alias: "y" }),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PushOptions = { message?: string, publish?: boolean, force?: boolean, yes?: boolean }
|
|
33
|
+
|
|
34
|
+
export default defineCommand({
|
|
35
|
+
name: "push",
|
|
36
|
+
summary: "Push local changes as one new checkpoint",
|
|
37
|
+
flags: pushFlags,
|
|
38
|
+
examples: ['lecodes push -m "Tweak the menu"', "lecodes push --publish"],
|
|
39
|
+
run: ({ flags }) => runPush(flags),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** The push itself — `lecodes link --push` runs it too. */
|
|
43
|
+
export const runPush = async (opts: PushOptions): Promise<void> => {
|
|
44
|
+
const { root, manifest, apiUrl, token } = linkedProject()
|
|
45
|
+
const publish = !!opts.publish
|
|
32
46
|
const scan = scanProject(root, manifest)
|
|
33
47
|
const diff = diffLocal(manifest, scan)
|
|
34
48
|
if (!hasChanges(diff)) {
|
|
35
|
-
if (publish) {
|
|
36
|
-
|
|
37
|
-
success("No file changes — published the current project state.")
|
|
38
|
-
} else {
|
|
39
|
-
info(c.dim("Working tree clean — nothing to push."))
|
|
40
|
-
}
|
|
49
|
+
if (publish) { await publishProject(apiUrl, token, manifest.uuid); success("No file changes — published the current project state.") }
|
|
50
|
+
else info(c.dim("Working tree clean — nothing to push."))
|
|
41
51
|
// Still flush: a queued answer must not be stuck behind "no files changed", which is exactly the
|
|
42
52
|
// state you're in after resolving a comment about something you already pushed.
|
|
43
|
-
await flushComments(
|
|
53
|
+
await flushComments()
|
|
44
54
|
return
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
const force = flagBool(args, "force")
|
|
48
57
|
// Concurrency guard: a push rewrites the server's WHOLE tree (set-tree), so anything that changed
|
|
49
58
|
// on the server since we cloned/pulled would be clobbered. Two kinds of drift to catch:
|
|
50
|
-
if (!force && manifest.baseHeadId !== null) {
|
|
59
|
+
if (!opts.force && manifest.baseHeadId !== null) {
|
|
51
60
|
const commits = await getCommits(apiUrl, token, manifest.uuid)
|
|
52
61
|
// (1) A new checkpoint was frozen on the server — that advances the head id.
|
|
53
62
|
if (commits.headId !== manifest.baseHeadId) {
|
|
@@ -58,28 +67,24 @@ export const push = async (args: Args) => {
|
|
|
58
67
|
// manifest (e.g. a resource was added in the editor) a blind push would silently DELETE it. Bail.
|
|
59
68
|
if (commits.dirty) {
|
|
60
69
|
const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest)
|
|
61
|
-
if (drift) {
|
|
62
|
-
throw new CliError(`The project has uncommitted changes in the web editor your checkout doesn't have (${drift}). Run \`lecodes pull\` to take them, or \`lecodes push --force\` to overwrite the server.`)
|
|
63
|
-
}
|
|
70
|
+
if (drift) throw new CliError(`The project has uncommitted changes in the web editor your checkout doesn't have (${drift}). Run \`lecodes pull\` to take them, or \`lecodes push --force\` to overwrite the server.`)
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
// With --force we may clobber server-side edits. Show exactly what changes on the server and
|
|
68
75
|
// confirm (interactive only; skip with --yes or when piped).
|
|
69
|
-
if (force) {
|
|
76
|
+
if (opts.force) {
|
|
70
77
|
const project = await getProject(apiUrl, token, manifest.uuid)
|
|
71
78
|
const serverDiff = computeServerDiff(project, scan)
|
|
72
79
|
if (serverDiff.length) {
|
|
73
80
|
info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`))
|
|
74
81
|
const label: Record<string, string> = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") }
|
|
75
82
|
for (const e of serverDiff) log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""))
|
|
76
|
-
if (process.stdin.isTTY && !
|
|
77
|
-
if (!(await confirm("Overwrite the server with your local copy?", false))) { info(c.dim("Aborted.")); return }
|
|
78
|
-
}
|
|
83
|
+
if (process.stdin.isTTY && !opts.yes && !(await confirm("Overwrite the server with your local copy?", false))) { info(c.dim("Aborted.")); return }
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
const message = (
|
|
87
|
+
const message = (opts.message ?? await prompt("Checkpoint message")) || "Update from lecodes CLI"
|
|
83
88
|
|
|
84
89
|
// The identity source for a path: the same path in the manifest, else its move origin.
|
|
85
90
|
const sourceFor = (path: string): ManifestFile | undefined =>
|
|
@@ -92,10 +97,8 @@ export const push = async (args: Args) => {
|
|
|
92
97
|
const kind: FileKind = source?.type ?? classifyKind(basename(file.path))
|
|
93
98
|
const entry: SyncFileInput = { path: file.path, type: kind }
|
|
94
99
|
if (source) entry.assetId = source.id
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
entry.text = readFileSync(file.absPath, "utf8")
|
|
98
|
-
} else if (!source || source.sha !== file.sha) {
|
|
100
|
+
if (isTextKind(kind)) entry.text = readFileSync(file.absPath, "utf8")
|
|
101
|
+
else if (!source || source.sha !== file.sha) {
|
|
99
102
|
// New or changed binary — upload it and reference the fresh blob.
|
|
100
103
|
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename(file.path), readFileSync(file.absPath))
|
|
101
104
|
entry.blobFileId = blob.id
|
|
@@ -104,7 +107,6 @@ export const push = async (args: Args) => {
|
|
|
104
107
|
// Else: unchanged binary — assetId alone tells the server to keep the existing blob.
|
|
105
108
|
files.push(entry)
|
|
106
109
|
}
|
|
107
|
-
|
|
108
110
|
const folders: SyncFolderInput[] = scan.dirs.map(path => ({ path }))
|
|
109
111
|
|
|
110
112
|
if (uploaded > 0) info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`))
|
|
@@ -118,9 +120,7 @@ export const push = async (args: Args) => {
|
|
|
118
120
|
// Rebuild the manifest from the authoritative server tree + local hashes.
|
|
119
121
|
const shaByPath = new Map(scan.files.map(f => [f.path, f.sha]))
|
|
120
122
|
const newFiles: Record<string, ManifestFile> = {}
|
|
121
|
-
for (const node of res.nodes) {
|
|
122
|
-
newFiles[node.path] = { id: node.id, type: node.type as FileKind, sha: shaByPath.get(node.path) ?? "" }
|
|
123
|
-
}
|
|
123
|
+
for (const node of res.nodes) newFiles[node.path] = { id: node.id, type: node.type as FileKind, sha: shaByPath.get(node.path) ?? "" }
|
|
124
124
|
writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles })
|
|
125
125
|
|
|
126
126
|
const counts = [
|
|
@@ -131,19 +131,17 @@ export const push = async (args: Args) => {
|
|
|
131
131
|
].filter(Boolean).join(", ")
|
|
132
132
|
success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`)
|
|
133
133
|
if (publish) reportPublish(res)
|
|
134
|
-
await flushComments(
|
|
134
|
+
await flushComments()
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** Send the design board's queued comment writes, now that the design they describe is up. Silent
|
|
138
138
|
* when there's no design folder, no queue, or no login — this is a rider on the push, not a step
|
|
139
139
|
* the user asked for. */
|
|
140
|
-
const flushComments = async (
|
|
140
|
+
const flushComments = async (): Promise<void> => {
|
|
141
141
|
const { flushDesignComments } = await import("./design")
|
|
142
|
-
const result = await flushDesignComments(
|
|
142
|
+
const result = await flushDesignComments()
|
|
143
143
|
if (!result) return
|
|
144
|
-
if (result.threads) {
|
|
145
|
-
success(`Sent ${result.sent} comment update${result.sent === 1 ? "" : "s"} across ${result.threads} thread${result.threads === 1 ? "" : "s"}.`)
|
|
146
|
-
}
|
|
144
|
+
if (result.threads) success(`Sent ${result.sent} comment update${result.sent === 1 ? "" : "s"} across ${result.threads} thread${result.threads === 1 ? "" : "s"}.`)
|
|
147
145
|
for (const failure of result.failures) warn(`Comment not sent — ${failure} (still queued; the next push retries it).`)
|
|
148
146
|
}
|
|
149
147
|
|
|
@@ -157,17 +155,13 @@ const serverPathDrift = (project: Project, manifest: Manifest): string | null =>
|
|
|
157
155
|
const manifestPaths = new Set(Object.keys(manifest.files))
|
|
158
156
|
const added = [...serverPaths].filter(p => !manifestPaths.has(p))
|
|
159
157
|
const removed = [...manifestPaths].filter(p => !serverPaths.has(p))
|
|
160
|
-
const describe = (label: string, paths: string[]) =>
|
|
161
|
-
|
|
162
|
-
const parts = [
|
|
163
|
-
added.length ? describe("added", added) : "",
|
|
164
|
-
removed.length ? describe("removed", removed) : "",
|
|
165
|
-
].filter(Boolean)
|
|
158
|
+
const describe = (label: string, paths: string[]) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`
|
|
159
|
+
const parts = [added.length ? describe("added", added) : "", removed.length ? describe("removed", removed) : ""].filter(Boolean)
|
|
166
160
|
return parts.length ? parts.join("; ") : null
|
|
167
161
|
}
|
|
168
162
|
|
|
169
163
|
/** Report the outcome of a --publish deploy bundled with the push (best-effort on the server). */
|
|
170
|
-
const reportPublish = (res: { published?: boolean, publishError?: string }) => {
|
|
164
|
+
const reportPublish = (res: { published?: boolean, publishError?: string }): void => {
|
|
171
165
|
if (res.published) success("Published — the project is now live.")
|
|
172
166
|
else warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`)
|
|
173
167
|
}
|
package/src/commands/render.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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
|
|
@@ -32,29 +33,45 @@ import { loadPeer } from "../peerInstall"
|
|
|
32
33
|
* lecodes-renderer (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-renderer / @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
|
+
frames: num("(--desktop) frames to run before the shot — headroom for async loads", { default: 60 }),
|
|
69
|
+
"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>" }),
|
|
70
|
+
renderer: str("(--desktop) host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- helpers `lecodes test` shares -----------------------------------------------------------
|
|
74
|
+
|
|
58
75
|
// Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
|
|
59
76
|
// edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
|
|
60
77
|
// inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
|
|
@@ -157,11 +174,82 @@ const parseClip = (raw: string | undefined): string | { x: number, y: number, wi
|
|
|
157
174
|
throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
|
|
158
175
|
}
|
|
159
176
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
/** `--png` / `--map` take an optional file: bare = the default name, absent = off. */
|
|
178
|
+
const optionalFile = (value: string | undefined, fallback: string): string | undefined =>
|
|
179
|
+
value === undefined ? undefined : value || fallback
|
|
180
|
+
|
|
181
|
+
// ---- the command -----------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const renderFlags = {
|
|
184
|
+
state: str("([file]) the state to pass a (state) => UIScreen screen", { value: "<name>" }),
|
|
185
|
+
dir: str("([file]) the design folder a bare screen id resolves in (default design/)", { value: "<folder>" }),
|
|
186
|
+
...outFlag,
|
|
187
|
+
png: str("rasterize a PNG (default screenshot.png; needs @napi-rs/canvas)", { value: "[file]", optional: true }),
|
|
188
|
+
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 }),
|
|
189
|
+
"label-all": bool("(--map) label every object, not only the named markers"),
|
|
190
|
+
"max-px": num("(--map) longest edge of the map image, px"),
|
|
191
|
+
script: str("drive the app through a step list (tap/type/scroll/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop: tap/drag/hold take { x, y } coordinates (no names — the host has no UI tree; type/scroll/clip unavailable), expect.log is checked, text/node/screen/value are skipped; per-step screenshots + final --png", { value: "<file>" }),
|
|
192
|
+
"out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
|
|
193
|
+
clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
|
|
194
|
+
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>" }),
|
|
195
|
+
scale: num("PNG device-pixel scale", { default: 2 }),
|
|
196
|
+
time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
|
|
197
|
+
"no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
|
|
198
|
+
timeout: num("hard cap on the whole render before aborting (default 15000, + 3000 per script step; --desktop 60000, + 5000 per step)", { value: "<ms>" }),
|
|
199
|
+
...viewportFlags,
|
|
200
|
+
...bundleFlags,
|
|
201
|
+
...desktopRenderFlags,
|
|
202
|
+
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>" }),
|
|
203
|
+
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>" }),
|
|
204
|
+
"look-at": str("(--desktop) the point --camera looks at", { value: "<x,y,z>" }),
|
|
205
|
+
fov: num("(--desktop) vertical field of view for --view / --camera, degrees", { value: "<deg>" }),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type RenderFlags = FlagValues<typeof renderFlags>
|
|
209
|
+
|
|
210
|
+
export default defineCommand({
|
|
211
|
+
name: "render",
|
|
212
|
+
summary: "Render the local project headless (JSON / PNG), or through the desktop host",
|
|
213
|
+
usage: "[file]",
|
|
214
|
+
description: "Needs the optional lecodes-renderer 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).",
|
|
215
|
+
flags: renderFlags,
|
|
216
|
+
examples: [
|
|
217
|
+
"lecodes render -o ui.json",
|
|
218
|
+
"lecodes render --png shots/home.png --device iphone",
|
|
219
|
+
"lecodes render login --state empty --png",
|
|
220
|
+
"lecodes render --script tests/hero.flow.json --out-dir tests/.shots --logs",
|
|
221
|
+
"lecodes render --desktop --png --view iso",
|
|
222
|
+
],
|
|
223
|
+
run: ({ args, flags }) => flags.desktop ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ---- --desktop -------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
229
|
+
const parseVec3Flag = (name: string, raw: string): string => {
|
|
230
|
+
const parts = raw.split(",").map((p) => p.trim())
|
|
231
|
+
if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
|
|
232
|
+
return parts.join(",")
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
|
|
236
|
+
const resolveInspectionCamera = ({ camera: cam, "look-at": look, view, fov }: Pick<RenderFlags, "camera" | "look-at" | "view" | "fov">): { camera?: string, view?: string } => {
|
|
237
|
+
if (fov !== undefined && !(fov > 0 && fov < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
|
|
238
|
+
const fovSuffix = fov !== undefined ? `;${fov}` : ""
|
|
239
|
+
if (cam !== undefined || look !== undefined) {
|
|
240
|
+
if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
|
|
241
|
+
if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
|
|
242
|
+
return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
|
|
243
|
+
}
|
|
244
|
+
if (view !== undefined) {
|
|
245
|
+
if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
|
|
246
|
+
return { view: `${view}${fovSuffix}` }
|
|
247
|
+
}
|
|
248
|
+
if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
|
|
249
|
+
return {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const renderDesktop = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
|
|
165
253
|
// A project with a desktop block renders at its configured window (this IS the desktop host);
|
|
166
254
|
// the phone-portrait literal stays only for projects that never declared one.
|
|
167
255
|
let root: string
|
|
@@ -169,32 +257,30 @@ const renderDesktop = async (args: Args) => {
|
|
|
169
257
|
const win = readAppConfigAt(root)?.desktop?.window
|
|
170
258
|
// `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
|
|
171
259
|
// runs the same bundle, so a design screen renders through the real tgfx UI here.
|
|
172
|
-
const screen =
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const logs = flagBool(args, "logs")
|
|
260
|
+
const screen = screenTargetFrom(root, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
|
|
261
|
+
const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
|
|
262
|
+
const width = flags.width || scenario?.width || screen?.frame?.[0] || win?.width || 390
|
|
263
|
+
const height = flags.height || scenario?.height || screen?.frame?.[1] || win?.height || 844
|
|
264
|
+
const frames = flags.frames || 60
|
|
265
|
+
const pngFlag = flags.png || "screenshot.png"
|
|
266
|
+
const logs = flags.logs
|
|
180
267
|
// --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
|
|
181
268
|
// key / drag / wait means the same game time on every machine; plain renders keep the wall clock
|
|
182
269
|
// (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}".`)
|
|
270
|
+
const fixedDtMs = flags["fixed-dt"] ?? (scenario ? 16 : 0)
|
|
271
|
+
if (fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtMs}".`)
|
|
186
272
|
|
|
187
273
|
// Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
|
|
188
274
|
// [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
|
|
189
275
|
// scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
|
|
190
|
-
const { camera, view } = resolveInspectionCamera(
|
|
276
|
+
const { camera, view } = resolveInspectionCamera(flags)
|
|
191
277
|
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
278
|
|
|
193
|
-
// One renderer decision: the host binary AND the staged .filamat variant (see
|
|
194
|
-
const desktopRenderer = resolveDesktopRenderer(root,
|
|
279
|
+
// One renderer decision: the host binary AND the staged .filamat variant (see app/shared).
|
|
280
|
+
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
195
281
|
// Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
|
|
196
282
|
const { dirs: pluginDirs } = desktopPluginLibraries(root)
|
|
197
|
-
const { js, resources } = await buildBundle(
|
|
283
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
198
284
|
localAssets: true, desktopRenderer,
|
|
199
285
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
200
286
|
})
|
|
@@ -204,10 +290,10 @@ const renderDesktop = async (args: Args) => {
|
|
|
204
290
|
if (scenario) {
|
|
205
291
|
// Scripted native run: translate the scenario into the host's command file, let the host drive
|
|
206
292
|
// it (it self-terminates), then read its markers back into per-step results.
|
|
207
|
-
const outDir =
|
|
293
|
+
const outDir = flags["out-dir"]
|
|
208
294
|
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
|
|
209
295
|
for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
|
|
210
|
-
const timeoutMs =
|
|
296
|
+
const timeoutMs = flags.timeout || (60000 + plan.steps.length * 5000)
|
|
211
297
|
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
|
|
212
298
|
const { output } = await runDesktopRender(js, resources, {
|
|
213
299
|
renderer: desktopRenderer, pluginDirs,
|
|
@@ -229,7 +315,7 @@ const renderDesktop = async (args: Args) => {
|
|
|
229
315
|
process.exit(result.ok ? 0 : 1)
|
|
230
316
|
}
|
|
231
317
|
|
|
232
|
-
const timeoutMs =
|
|
318
|
+
const timeoutMs = flags.timeout || 60000
|
|
233
319
|
note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
|
|
234
320
|
await runDesktopRender(js, resources, {
|
|
235
321
|
renderer: desktopRenderer, pluginDirs,
|
|
@@ -238,72 +324,45 @@ const renderDesktop = async (args: Args) => {
|
|
|
238
324
|
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
|
239
325
|
}
|
|
240
326
|
|
|
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)
|
|
327
|
+
// ---- headless --------------------------------------------------------------------------------
|
|
267
328
|
|
|
268
|
-
|
|
269
|
-
const scenario =
|
|
329
|
+
const renderHeadless = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
|
|
330
|
+
const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
|
|
270
331
|
|
|
271
332
|
// `render <file>`: render that one screen module instead of the app's own entrypoint.
|
|
272
333
|
let projectRoot: string
|
|
273
334
|
try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
|
|
274
|
-
const screen =
|
|
335
|
+
const screen = screenTargetFrom(projectRoot, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
|
|
275
336
|
|
|
276
337
|
// Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
|
|
277
338
|
// a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
|
|
278
339
|
const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
|
|
279
|
-
const timeoutMs =
|
|
340
|
+
const timeoutMs = flags.timeout || (15000 + stepCount * 3000)
|
|
280
341
|
const killer = setTimeout(() => {
|
|
281
342
|
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
|
|
282
343
|
process.exit(1)
|
|
283
344
|
}, timeoutMs)
|
|
284
345
|
killer.unref?.()
|
|
285
346
|
|
|
286
|
-
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(
|
|
347
|
+
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
287
348
|
const renderer = await requireRenderer()
|
|
288
349
|
|
|
289
350
|
// Flags override the scenario file's envelope; both fall back to the project default (a
|
|
290
351
|
// desktop-only app.json renders desktop-sized), then the phone-portrait literals.
|
|
291
352
|
// A design screen carries its own board frame (meta.json), which beats the app-wide default.
|
|
292
353
|
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")
|
|
354
|
+
const width = flags.width || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
|
|
355
|
+
const height = flags.height || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
|
|
356
|
+
|
|
357
|
+
// Shared snapshot-timing options. `--logs` forwards the app's console to stderr (kept off stdout
|
|
358
|
+
// so it never corrupts the JSON/PNG result there).
|
|
359
|
+
const onConsole = flags.logs
|
|
301
360
|
? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
|
|
302
361
|
: undefined
|
|
303
362
|
// Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
|
|
304
363
|
// 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(
|
|
364
|
+
const envelopeFixtures = resolveFixtures(scenario?.fixtures, flags.script !== undefined ? dirname(resolve(flags.script)) : ".")
|
|
365
|
+
const flagFixtures = resolveFixtures(flags.fixtures, ".")
|
|
307
366
|
const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
|
|
308
367
|
if (fixtures) {
|
|
309
368
|
try {
|
|
@@ -314,13 +373,13 @@ export const render = async (args: Args) => {
|
|
|
314
373
|
}
|
|
315
374
|
|
|
316
375
|
const timing = {
|
|
317
|
-
settleMs:
|
|
318
|
-
waitNetwork:
|
|
376
|
+
settleMs: flags.settle ?? scenario?.settle,
|
|
377
|
+
waitNetwork: flags["wait-network"],
|
|
319
378
|
localAssets: useLocalAssets,
|
|
320
379
|
fixtures,
|
|
321
|
-
timeMs:
|
|
322
|
-
afterAnimations: !
|
|
323
|
-
...resolveSafeAreaChoice(
|
|
380
|
+
timeMs: flags.time,
|
|
381
|
+
afterAnimations: !flags["no-after-animations"],
|
|
382
|
+
...resolveSafeAreaChoice(flags["safe-area"] ?? scenario?.safeArea, flags.device ?? scenario?.device),
|
|
324
383
|
onConsole,
|
|
325
384
|
}
|
|
326
385
|
|
|
@@ -331,7 +390,7 @@ export const render = async (args: Args) => {
|
|
|
331
390
|
// --script: drive the compiled app through the scenario steps against a live session.
|
|
332
391
|
if (scenario) {
|
|
333
392
|
const steps = renderer.parseScenarioSteps(scenario.steps)
|
|
334
|
-
const outDir =
|
|
393
|
+
const outDir = flags["out-dir"]
|
|
335
394
|
// Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
|
|
336
395
|
// PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
|
|
337
396
|
const needsRaster = steps.some((s) => s.screenshot !== undefined)
|
|
@@ -356,12 +415,12 @@ export const render = async (args: Args) => {
|
|
|
356
415
|
// --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
|
|
357
416
|
// markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
|
|
358
417
|
// (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
|
|
359
|
-
const mapFlag =
|
|
418
|
+
const mapFlag = optionalFile(flags.map, "map.png")
|
|
360
419
|
if (mapFlag !== undefined) {
|
|
361
420
|
note(`Rendering map…`)
|
|
362
421
|
const title = root.split(/[\\/]/).pop()
|
|
363
422
|
const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
|
|
364
|
-
...timing, maxPx:
|
|
423
|
+
...timing, maxPx: flags["max-px"] || undefined, title, labelAll: flags["label-all"],
|
|
365
424
|
})
|
|
366
425
|
for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
|
|
367
426
|
const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
|
|
@@ -374,12 +433,12 @@ export const render = async (args: Args) => {
|
|
|
374
433
|
}
|
|
375
434
|
|
|
376
435
|
// --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
|
|
377
|
-
const pngFlag =
|
|
436
|
+
const pngFlag = optionalFile(flags.png, "screenshot.png")
|
|
378
437
|
if (pngFlag !== undefined) {
|
|
379
438
|
note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
|
|
380
|
-
const clip = parseClip(
|
|
439
|
+
const clip = parseClip(flags.clip)
|
|
381
440
|
const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
|
|
382
|
-
width, height, scale:
|
|
441
|
+
width, height, scale: flags.scale || 2, ...timing, clip,
|
|
383
442
|
})
|
|
384
443
|
for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
|
|
385
444
|
if (isNullSink(pngFlag)) {
|
|
@@ -397,7 +456,7 @@ export const render = async (args: Args) => {
|
|
|
397
456
|
|
|
398
457
|
for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
|
|
399
458
|
|
|
400
|
-
const out =
|
|
459
|
+
const out = flags.out
|
|
401
460
|
if (out && isNullSink(out)) {
|
|
402
461
|
note(`Discarded output (${out})`)
|
|
403
462
|
return done((cb) => cb())
|