lecodes-cli 2.4.4 → 2.4.6
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/dist/index.js +10891 -18222
- package/package.json +8 -8
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +2 -2
- package/runtime/web-lite/assets/{index-oPsDD47E.js → index-Bd7pdF7u.js} +1 -1
- package/runtime/web-lite/assets/scene3d-DXpVYhGy.js +1105 -0
- package/runtime/web-lite/features.json +2 -2
- package/runtime/web-lite/lite.js +2 -2
- package/src/commands/app/templates/android.ts +17 -7
- package/src/commands/init/index.ts +195 -195
- package/src/commands/shared.ts +79 -79
- package/src/commands/thumbs.ts +185 -185
- package/src/compile/screenEntry.ts +123 -123
- package/src/project/ignore.ts +164 -164
- package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +0 -1105
package/src/commands/thumbs.ts
CHANGED
|
@@ -1,185 +1,185 @@
|
|
|
1
|
-
import { createRequire } from "node:module"
|
|
2
|
-
import { execFileSync } from "node:child_process"
|
|
3
|
-
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
4
|
-
import { tmpdir } from "node:os"
|
|
5
|
-
import { extname, join, relative, resolve } from "node:path"
|
|
6
|
-
import { loadPeer } from "../hosts/peerInstall"
|
|
7
|
-
import { projectRootOf } from "./scene"
|
|
8
|
-
import { CliError, bool, c, defineCommand, endProgress, log, note, num, progress, warnErr } from "../cli"
|
|
9
|
-
import { toKey } from "../project/paths"
|
|
10
|
-
|
|
11
|
-
/*
|
|
12
|
-
* `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
|
|
13
|
-
*
|
|
14
|
-
* The editor renders previews in a hidden browser host (3D assets need an engine), but image
|
|
15
|
-
* thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
|
|
16
|
-
* command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
|
|
17
|
-
* lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
|
|
18
|
-
* texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
|
|
19
|
-
* spot.
|
|
20
|
-
*
|
|
21
|
-
* Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
|
|
22
|
-
* sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
|
|
23
|
-
* big project is nearly free. `--force` ignores that.
|
|
24
|
-
*
|
|
25
|
-
* Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
|
|
26
|
-
* else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
|
|
27
|
-
* KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
|
|
28
|
-
* the extracted PNG then takes the same resize path.
|
|
29
|
-
*
|
|
30
|
-
* NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
|
|
31
|
-
* editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
|
|
32
|
-
* that ever becomes a batch job).
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const DEFAULT_SIZE = 256
|
|
36
|
-
const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
|
|
37
|
-
const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
|
|
38
|
-
|
|
39
|
-
/** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
|
|
40
|
-
const findKtx = (): string | null => {
|
|
41
|
-
const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
|
|
42
|
-
const candidates = [
|
|
43
|
-
process.env.LECODES_KTX,
|
|
44
|
-
process.env.KTX_CLI,
|
|
45
|
-
process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
|
|
46
|
-
process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
|
|
47
|
-
process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
|
|
48
|
-
"/usr/local/bin/ktx",
|
|
49
|
-
"/opt/homebrew/bin/ktx",
|
|
50
|
-
].filter((p): p is string => !!p)
|
|
51
|
-
for (const path of candidates) { if (existsSync(path)) return path }
|
|
52
|
-
try {
|
|
53
|
-
execFileSync(exe, [ "--version" ], { stdio: "ignore" })
|
|
54
|
-
return exe
|
|
55
|
-
} catch { return null }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
type Resizer = (file: string, size: number) => Promise<Uint8Array>
|
|
59
|
-
|
|
60
|
-
/** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
|
|
61
|
-
* installed — jimp then covers everything but webp. */
|
|
62
|
-
const sharpResizer = (): Resizer | null => {
|
|
63
|
-
try {
|
|
64
|
-
const req = createRequire(import.meta.url)
|
|
65
|
-
const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
|
|
66
|
-
return async (file, size) =>
|
|
67
|
-
sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
|
|
68
|
-
} catch { return null }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const jimpResizer = (): Resizer => async (file, size) => {
|
|
72
|
-
const { Jimp } = await import("jimp")
|
|
73
|
-
const img = await Jimp.read(file)
|
|
74
|
-
if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
|
|
75
|
-
return await img.getBuffer("image/png")
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
|
|
79
|
-
const ktxToPng = (ktx: string, file: string, out: string): void => {
|
|
80
|
-
const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
|
|
81
|
-
try {
|
|
82
|
-
run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
|
|
83
|
-
} catch (e: any) {
|
|
84
|
-
// Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
|
|
85
|
-
run([ "extract", "--level", "0", file, out ])
|
|
86
|
-
if (!existsSync(out)) throw e
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
|
|
91
|
-
* (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
|
|
92
|
-
* end up rendering itself. */
|
|
93
|
-
const collect = (dir: string): string[] => {
|
|
94
|
-
const out: string[] = []
|
|
95
|
-
for (const name of readdirSync(dir)) {
|
|
96
|
-
if (name === "node_modules" || name.startsWith(".")) continue
|
|
97
|
-
const full = join(dir, name)
|
|
98
|
-
if (statSync(full).isDirectory()) out.push(...collect(full))
|
|
99
|
-
else if (IMAGE_EXT.test(name)) out.push(full)
|
|
100
|
-
}
|
|
101
|
-
return out
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export default defineCommand({
|
|
105
|
-
name: "thumbs",
|
|
106
|
-
summary: "Asset thumbnails for the scene editor, without a browser",
|
|
107
|
-
usage: "[path…]",
|
|
108
|
-
description: "Decodes the project's images (KTX2 too, via KTX-Software's ktx tool) into the editor's preview cache. Incremental: the cached name carries the asset's content hash, so unchanged files are skipped. Models, materials and prefabs are rendered by the editor, not here.",
|
|
109
|
-
flags: {
|
|
110
|
-
force: bool("redo even the up-to-date ones"),
|
|
111
|
-
size: num("longest edge, px", { default: DEFAULT_SIZE }),
|
|
112
|
-
},
|
|
113
|
-
run: ({ args, flags }) => thumbs(args, flags.size, flags.force),
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
const thumbs = async (targets: string[], size: number, force: boolean): Promise<void> => {
|
|
117
|
-
const root = projectRootOf(process.cwd())
|
|
118
|
-
|
|
119
|
-
// The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
|
|
120
|
-
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
|
|
121
|
-
|
|
122
|
-
// Targets: the paths given (a file or a folder each), else the whole project.
|
|
123
|
-
let files: string[] = []
|
|
124
|
-
const rendered: string[] = []
|
|
125
|
-
if (targets.length > 0) {
|
|
126
|
-
for (const arg of targets) {
|
|
127
|
-
const abs = resolve(process.cwd(), arg)
|
|
128
|
-
if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
|
|
129
|
-
if (statSync(abs).isDirectory()) files.push(...collect(abs))
|
|
130
|
-
else if (IMAGE_EXT.test(abs)) files.push(abs)
|
|
131
|
-
else if (RENDERED_EXT.test(abs)) rendered.push(arg)
|
|
132
|
-
else throw new CliError(`Not a previewable asset: ${arg}`)
|
|
133
|
-
}
|
|
134
|
-
} else {
|
|
135
|
-
files = collect(root)
|
|
136
|
-
}
|
|
137
|
-
files = [ ...new Set(files) ]
|
|
138
|
-
|
|
139
|
-
if (files.length === 0 && rendered.length === 0) {
|
|
140
|
-
log("No images to thumbnail.")
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
|
|
145
|
-
const sharp = sharpResizer()
|
|
146
|
-
const resize = sharp ?? jimpResizer()
|
|
147
|
-
const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
|
|
148
|
-
|
|
149
|
-
let made = 0, fresh = 0
|
|
150
|
-
const skipped: string[] = []
|
|
151
|
-
try {
|
|
152
|
-
for (const [ i, file ] of files.entries()) {
|
|
153
|
-
const asset = "./" + toKey(relative(root, file))
|
|
154
|
-
progress(i, files.length, asset)
|
|
155
|
-
const hash = sceneServer.hashAsset(root, asset)
|
|
156
|
-
if (!hash) continue
|
|
157
|
-
if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
|
|
158
|
-
|
|
159
|
-
const ext = extname(file).toLowerCase()
|
|
160
|
-
if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
|
|
161
|
-
try {
|
|
162
|
-
let source = file
|
|
163
|
-
if (ext === ".ktx2") {
|
|
164
|
-
if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
|
|
165
|
-
source = join(tmp, `${i}.png`)
|
|
166
|
-
ktxToPng(ktx, file, source)
|
|
167
|
-
}
|
|
168
|
-
sceneServer.writePreviewFile(root, asset, await resize(source, size))
|
|
169
|
-
made++
|
|
170
|
-
} catch (e: any) {
|
|
171
|
-
skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
} finally {
|
|
175
|
-
endProgress()
|
|
176
|
-
rmSync(tmp, { recursive: true, force: true })
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
|
|
180
|
-
for (const s of skipped) warnErr(s)
|
|
181
|
-
if (rendered.length > 0) {
|
|
182
|
-
warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
|
|
183
|
-
}
|
|
184
|
-
note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
|
|
185
|
-
}
|
|
1
|
+
import { createRequire } from "node:module"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { extname, join, relative, resolve } from "node:path"
|
|
6
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
7
|
+
import { projectRootOf } from "./scene"
|
|
8
|
+
import { CliError, bool, c, defineCommand, endProgress, log, note, num, progress, warnErr } from "../cli"
|
|
9
|
+
import { toKey } from "../project/paths"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
|
|
13
|
+
*
|
|
14
|
+
* The editor renders previews in a hidden browser host (3D assets need an engine), but image
|
|
15
|
+
* thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
|
|
16
|
+
* command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
|
|
17
|
+
* lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
|
|
18
|
+
* texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
|
|
19
|
+
* spot.
|
|
20
|
+
*
|
|
21
|
+
* Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
|
|
22
|
+
* sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
|
|
23
|
+
* big project is nearly free. `--force` ignores that.
|
|
24
|
+
*
|
|
25
|
+
* Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
|
|
26
|
+
* else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
|
|
27
|
+
* KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
|
|
28
|
+
* the extracted PNG then takes the same resize path.
|
|
29
|
+
*
|
|
30
|
+
* NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
|
|
31
|
+
* editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
|
|
32
|
+
* that ever becomes a batch job).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const DEFAULT_SIZE = 256
|
|
36
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
|
|
37
|
+
const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
|
|
38
|
+
|
|
39
|
+
/** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
|
|
40
|
+
const findKtx = (): string | null => {
|
|
41
|
+
const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
|
|
42
|
+
const candidates = [
|
|
43
|
+
process.env.LECODES_KTX,
|
|
44
|
+
process.env.KTX_CLI,
|
|
45
|
+
process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
|
|
46
|
+
process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
|
|
47
|
+
process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
|
|
48
|
+
"/usr/local/bin/ktx",
|
|
49
|
+
"/opt/homebrew/bin/ktx",
|
|
50
|
+
].filter((p): p is string => !!p)
|
|
51
|
+
for (const path of candidates) { if (existsSync(path)) return path }
|
|
52
|
+
try {
|
|
53
|
+
execFileSync(exe, [ "--version" ], { stdio: "ignore" })
|
|
54
|
+
return exe
|
|
55
|
+
} catch { return null }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Resizer = (file: string, size: number) => Promise<Uint8Array>
|
|
59
|
+
|
|
60
|
+
/** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
|
|
61
|
+
* installed — jimp then covers everything but webp. */
|
|
62
|
+
const sharpResizer = (): Resizer | null => {
|
|
63
|
+
try {
|
|
64
|
+
const req = createRequire(import.meta.url)
|
|
65
|
+
const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
|
|
66
|
+
return async (file, size) =>
|
|
67
|
+
sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
|
|
68
|
+
} catch { return null }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const jimpResizer = (): Resizer => async (file, size) => {
|
|
72
|
+
const { Jimp } = await import("jimp")
|
|
73
|
+
const img = await Jimp.read(file)
|
|
74
|
+
if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
|
|
75
|
+
return await img.getBuffer("image/png")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
|
|
79
|
+
const ktxToPng = (ktx: string, file: string, out: string): void => {
|
|
80
|
+
const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
|
|
81
|
+
try {
|
|
82
|
+
run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
|
|
83
|
+
} catch (e: any) {
|
|
84
|
+
// Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
|
|
85
|
+
run([ "extract", "--level", "0", file, out ])
|
|
86
|
+
if (!existsSync(out)) throw e
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
|
|
91
|
+
* (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
|
|
92
|
+
* end up rendering itself. */
|
|
93
|
+
const collect = (dir: string): string[] => {
|
|
94
|
+
const out: string[] = []
|
|
95
|
+
for (const name of readdirSync(dir)) {
|
|
96
|
+
if (name === "node_modules" || name.startsWith(".")) continue
|
|
97
|
+
const full = join(dir, name)
|
|
98
|
+
if (statSync(full).isDirectory()) out.push(...collect(full))
|
|
99
|
+
else if (IMAGE_EXT.test(name)) out.push(full)
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default defineCommand({
|
|
105
|
+
name: "thumbs",
|
|
106
|
+
summary: "Asset thumbnails for the scene editor, without a browser",
|
|
107
|
+
usage: "[path…]",
|
|
108
|
+
description: "Decodes the project's images (KTX2 too, via KTX-Software's ktx tool) into the editor's preview cache. Incremental: the cached name carries the asset's content hash, so unchanged files are skipped. Models, materials and prefabs are rendered by the editor, not here.",
|
|
109
|
+
flags: {
|
|
110
|
+
force: bool("redo even the up-to-date ones"),
|
|
111
|
+
size: num("longest edge, px", { default: DEFAULT_SIZE }),
|
|
112
|
+
},
|
|
113
|
+
run: ({ args, flags }) => thumbs(args, flags.size, flags.force),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const thumbs = async (targets: string[], size: number, force: boolean): Promise<void> => {
|
|
117
|
+
const root = projectRootOf(process.cwd())
|
|
118
|
+
|
|
119
|
+
// The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
|
|
120
|
+
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
|
|
121
|
+
|
|
122
|
+
// Targets: the paths given (a file or a folder each), else the whole project.
|
|
123
|
+
let files: string[] = []
|
|
124
|
+
const rendered: string[] = []
|
|
125
|
+
if (targets.length > 0) {
|
|
126
|
+
for (const arg of targets) {
|
|
127
|
+
const abs = resolve(process.cwd(), arg)
|
|
128
|
+
if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
|
|
129
|
+
if (statSync(abs).isDirectory()) files.push(...collect(abs))
|
|
130
|
+
else if (IMAGE_EXT.test(abs)) files.push(abs)
|
|
131
|
+
else if (RENDERED_EXT.test(abs)) rendered.push(arg)
|
|
132
|
+
else throw new CliError(`Not a previewable asset: ${arg}`)
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
files = collect(root)
|
|
136
|
+
}
|
|
137
|
+
files = [ ...new Set(files) ]
|
|
138
|
+
|
|
139
|
+
if (files.length === 0 && rendered.length === 0) {
|
|
140
|
+
log("No images to thumbnail.")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
|
|
145
|
+
const sharp = sharpResizer()
|
|
146
|
+
const resize = sharp ?? jimpResizer()
|
|
147
|
+
const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
|
|
148
|
+
|
|
149
|
+
let made = 0, fresh = 0
|
|
150
|
+
const skipped: string[] = []
|
|
151
|
+
try {
|
|
152
|
+
for (const [ i, file ] of files.entries()) {
|
|
153
|
+
const asset = "./" + toKey(relative(root, file))
|
|
154
|
+
progress(i, files.length, asset)
|
|
155
|
+
const hash = sceneServer.hashAsset(root, asset)
|
|
156
|
+
if (!hash) continue
|
|
157
|
+
if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
|
|
158
|
+
|
|
159
|
+
const ext = extname(file).toLowerCase()
|
|
160
|
+
if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
|
|
161
|
+
try {
|
|
162
|
+
let source = file
|
|
163
|
+
if (ext === ".ktx2") {
|
|
164
|
+
if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
|
|
165
|
+
source = join(tmp, `${i}.png`)
|
|
166
|
+
ktxToPng(ktx, file, source)
|
|
167
|
+
}
|
|
168
|
+
sceneServer.writePreviewFile(root, asset, await resize(source, size))
|
|
169
|
+
made++
|
|
170
|
+
} catch (e: any) {
|
|
171
|
+
skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
endProgress()
|
|
176
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
|
|
180
|
+
for (const s of skipped) warnErr(s)
|
|
181
|
+
if (rendered.length > 0) {
|
|
182
|
+
warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
|
|
183
|
+
}
|
|
184
|
+
note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
|
|
185
|
+
}
|
|
@@ -1,123 +1,123 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
-
import { isAbsolute, join, relative, resolve } from "node:path"
|
|
3
|
-
import { screenFrame } from "../project/designMeta"
|
|
4
|
-
import { CliError } from "../cli"
|
|
5
|
-
import { toKey } from "../project/paths"
|
|
6
|
-
import type { CompileEntry } from "./projectCompile"
|
|
7
|
-
|
|
8
|
-
/*
|
|
9
|
-
* Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
|
|
10
|
-
* `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
|
|
11
|
-
* a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
|
|
12
|
-
* supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
|
|
13
|
-
* file written that way — inside design/ or not — renders with the full `lecodes render` flag set
|
|
14
|
-
* (--png/--clip/--script/--device/…) against the project's real imports and assets.
|
|
15
|
-
*
|
|
16
|
-
* Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** Extensions a screen module may carry (so `render design/screens/login` works too). */
|
|
20
|
-
const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
|
|
21
|
-
|
|
22
|
-
/** Where the generated entry lives — the project root, so its import specifier is the screen's
|
|
23
|
-
* own project key ("./design/screens/login"). Leading dot: never a real project file. */
|
|
24
|
-
const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
|
|
25
|
-
|
|
26
|
-
/** The design folder a bare screen id (`lecodes render login`) is looked up in. */
|
|
27
|
-
const DEFAULT_DESIGN_DIR = "design"
|
|
28
|
-
|
|
29
|
-
export type ScreenTarget = {
|
|
30
|
-
/** The virtual entry module — hand it to the compiler alongside the project's own entries. */
|
|
31
|
-
entry: CompileEntry
|
|
32
|
-
/** Path of that entry, for `entryOverride`. */
|
|
33
|
-
entryPath: string
|
|
34
|
-
/** The board frame from the design folder's meta.json, when this IS a design screen. */
|
|
35
|
-
frame?: [number, number]
|
|
36
|
-
/** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
|
|
37
|
-
label: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
|
|
41
|
-
|
|
42
|
-
/** Whether the argument names a path at all (vs a bare screen id). */
|
|
43
|
-
const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
|
|
44
|
-
|
|
45
|
-
/** Find the screen file: a path (with or without extension), or a bare id resolved against the
|
|
46
|
-
* design folder's screens/ (`lecodes render login`, honoring --dir). */
|
|
47
|
-
const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
|
|
48
|
-
const candidates = [resolve(process.cwd(), file)]
|
|
49
|
-
if (!hasSeparator(file)) {
|
|
50
|
-
candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
|
|
51
|
-
}
|
|
52
|
-
for (const base of candidates) {
|
|
53
|
-
if (existsSync(base) && statSync(base).isFile()) return base
|
|
54
|
-
for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
|
|
55
|
-
}
|
|
56
|
-
throw new CliError(
|
|
57
|
-
`No screen file at "${file}".` +
|
|
58
|
-
(hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
|
|
62
|
-
* or null when it isn't laid out as a design screen. */
|
|
63
|
-
const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
|
|
64
|
-
const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
|
|
65
|
-
if (!m) return null
|
|
66
|
-
const dir = join(root, m[1])
|
|
67
|
-
return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Resolve `<file>` into the virtual entry that opens its default export.
|
|
72
|
-
* `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
|
|
73
|
-
*/
|
|
74
|
-
const resolveScreenTarget = (
|
|
75
|
-
root: string, file: string, opts: { state?: string, dir?: string } = {},
|
|
76
|
-
): ScreenTarget => {
|
|
77
|
-
const abs = findScreenFile(root, file, opts.dir)
|
|
78
|
-
const rel = relative(root, abs)
|
|
79
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
80
|
-
throw new CliError(
|
|
81
|
-
`"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
|
|
82
|
-
`imports and asset() files resolve from the same tree.`)
|
|
83
|
-
}
|
|
84
|
-
const key = toKey(rel)
|
|
85
|
-
|
|
86
|
-
// A file with no default export compiles to an empty render; say so here instead.
|
|
87
|
-
if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
|
|
88
|
-
throw new CliError(
|
|
89
|
-
`${key.slice(1)} has no default export. A screen file ends with ` +
|
|
90
|
-
`\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
|
|
94
|
-
// chosen state (a bare call uses the parameter's default = the canonical state), a value is used
|
|
95
|
-
// as-is, and either way the screen is opened so the runtime has something to render.
|
|
96
|
-
const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
|
|
97
|
-
const spec = "." + key.replace(/\.[^./]+$/, "")
|
|
98
|
-
const design = designDirOf(root, key)
|
|
99
|
-
return {
|
|
100
|
-
entryPath: SCREEN_ENTRY_PATH,
|
|
101
|
-
entry: {
|
|
102
|
-
path: SCREEN_ENTRY_PATH,
|
|
103
|
-
type: "text",
|
|
104
|
-
text: `import screen from ${JSON.stringify(spec)}\n`
|
|
105
|
-
+ `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
|
|
106
|
-
+ `__screen.open()\n`,
|
|
107
|
-
},
|
|
108
|
-
frame: design ? screenFrame(design.dir, design.id) : undefined,
|
|
109
|
-
label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The screen target named on the command line (`render <file>`), or undefined when no screen was
|
|
114
|
-
* named. Rejects the flag combinations that would fight over the entrypoint. */
|
|
115
|
-
export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
|
|
116
|
-
export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
|
|
117
|
-
if (a.file === undefined) {
|
|
118
|
-
if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
|
|
119
|
-
return undefined
|
|
120
|
-
}
|
|
121
|
-
if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
|
|
122
|
-
return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
|
|
123
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path"
|
|
3
|
+
import { screenFrame } from "../project/designMeta"
|
|
4
|
+
import { CliError } from "../cli"
|
|
5
|
+
import { toKey } from "../project/paths"
|
|
6
|
+
import type { CompileEntry } from "./projectCompile"
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
|
|
10
|
+
* `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
|
|
11
|
+
* a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
|
|
12
|
+
* supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
|
|
13
|
+
* file written that way — inside design/ or not — renders with the full `lecodes render` flag set
|
|
14
|
+
* (--png/--clip/--script/--device/…) against the project's real imports and assets.
|
|
15
|
+
*
|
|
16
|
+
* Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Extensions a screen module may carry (so `render design/screens/login` works too). */
|
|
20
|
+
const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
|
|
21
|
+
|
|
22
|
+
/** Where the generated entry lives — the project root, so its import specifier is the screen's
|
|
23
|
+
* own project key ("./design/screens/login"). Leading dot: never a real project file. */
|
|
24
|
+
const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
|
|
25
|
+
|
|
26
|
+
/** The design folder a bare screen id (`lecodes render login`) is looked up in. */
|
|
27
|
+
const DEFAULT_DESIGN_DIR = "design"
|
|
28
|
+
|
|
29
|
+
export type ScreenTarget = {
|
|
30
|
+
/** The virtual entry module — hand it to the compiler alongside the project's own entries. */
|
|
31
|
+
entry: CompileEntry
|
|
32
|
+
/** Path of that entry, for `entryOverride`. */
|
|
33
|
+
entryPath: string
|
|
34
|
+
/** The board frame from the design folder's meta.json, when this IS a design screen. */
|
|
35
|
+
frame?: [number, number]
|
|
36
|
+
/** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
|
|
37
|
+
label: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
|
|
41
|
+
|
|
42
|
+
/** Whether the argument names a path at all (vs a bare screen id). */
|
|
43
|
+
const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
|
|
44
|
+
|
|
45
|
+
/** Find the screen file: a path (with or without extension), or a bare id resolved against the
|
|
46
|
+
* design folder's screens/ (`lecodes render login`, honoring --dir). */
|
|
47
|
+
const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
|
|
48
|
+
const candidates = [resolve(process.cwd(), file)]
|
|
49
|
+
if (!hasSeparator(file)) {
|
|
50
|
+
candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
|
|
51
|
+
}
|
|
52
|
+
for (const base of candidates) {
|
|
53
|
+
if (existsSync(base) && statSync(base).isFile()) return base
|
|
54
|
+
for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
|
|
55
|
+
}
|
|
56
|
+
throw new CliError(
|
|
57
|
+
`No screen file at "${file}".` +
|
|
58
|
+
(hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
|
|
62
|
+
* or null when it isn't laid out as a design screen. */
|
|
63
|
+
const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
|
|
64
|
+
const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
|
|
65
|
+
if (!m) return null
|
|
66
|
+
const dir = join(root, m[1])
|
|
67
|
+
return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve `<file>` into the virtual entry that opens its default export.
|
|
72
|
+
* `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
|
|
73
|
+
*/
|
|
74
|
+
const resolveScreenTarget = (
|
|
75
|
+
root: string, file: string, opts: { state?: string, dir?: string } = {},
|
|
76
|
+
): ScreenTarget => {
|
|
77
|
+
const abs = findScreenFile(root, file, opts.dir)
|
|
78
|
+
const rel = relative(root, abs)
|
|
79
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
80
|
+
throw new CliError(
|
|
81
|
+
`"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
|
|
82
|
+
`imports and asset() files resolve from the same tree.`)
|
|
83
|
+
}
|
|
84
|
+
const key = toKey(rel)
|
|
85
|
+
|
|
86
|
+
// A file with no default export compiles to an empty render; say so here instead.
|
|
87
|
+
if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
|
|
88
|
+
throw new CliError(
|
|
89
|
+
`${key.slice(1)} has no default export. A screen file ends with ` +
|
|
90
|
+
`\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
|
|
94
|
+
// chosen state (a bare call uses the parameter's default = the canonical state), a value is used
|
|
95
|
+
// as-is, and either way the screen is opened so the runtime has something to render.
|
|
96
|
+
const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
|
|
97
|
+
const spec = "." + key.replace(/\.[^./]+$/, "")
|
|
98
|
+
const design = designDirOf(root, key)
|
|
99
|
+
return {
|
|
100
|
+
entryPath: SCREEN_ENTRY_PATH,
|
|
101
|
+
entry: {
|
|
102
|
+
path: SCREEN_ENTRY_PATH,
|
|
103
|
+
type: "text",
|
|
104
|
+
text: `import screen from ${JSON.stringify(spec)}\n`
|
|
105
|
+
+ `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
|
|
106
|
+
+ `__screen.open()\n`,
|
|
107
|
+
},
|
|
108
|
+
frame: design ? screenFrame(design.dir, design.id) : undefined,
|
|
109
|
+
label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The screen target named on the command line (`render <file>`), or undefined when no screen was
|
|
114
|
+
* named. Rejects the flag combinations that would fight over the entrypoint. */
|
|
115
|
+
export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
|
|
116
|
+
export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
|
|
117
|
+
if (a.file === undefined) {
|
|
118
|
+
if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
|
|
122
|
+
return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
|
|
123
|
+
}
|