lecodes-cli 2.4.2 → 2.4.4

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.
@@ -1,79 +1,79 @@
1
- import { basename } from "node:path"
2
- import { bool, str, type FlagValues } from "../cli"
3
- import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
4
- import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
5
-
6
- /*
7
- * What several commands share: the flag groups (spread into a command's `flags:`) and the
8
- * "where am I / who am I" lookups every platform command starts with.
9
- */
10
-
11
- // ---- flag groups -----------------------------------------------------------------------------
12
-
13
- /** How the project is compiled — `compile`, `dev`, `render`, `test`, `desktop run`, `app sync` … */
14
- export const bundleFlags = {
15
- entry: str("compile from this entry instead of the detected one", { value: "<path>" }),
16
- "public-url": str("resolve assets from this URL prefix (default: the API origin / the working tree)", { value: "<url>" }),
17
- "remote-assets": bool("desktop hosts: load asset() resources from the server instead of the working tree"),
18
- "no-header": bool("omit the metadata header"),
19
- "no-minify": bool("keep the bundle readable (no compress / mangle)"),
20
- "no-sourcemap": bool("don't generate / inline the source map"),
21
- }
22
-
23
- /** `-o` for commands whose result is a file. */
24
- export const outFlag = { out: str("write to this file instead of stdout", { alias: "o", value: "<file>" }) }
25
-
26
- /** The compiler's view of the bundle flags (see compile.ts `buildBundle`). */
27
- export type BundleOptions = {
28
- entry?: string
29
- publicUrl?: string
30
- remoteAssets?: boolean
31
- header?: boolean
32
- minify?: boolean
33
- sourcemap?: boolean
34
- }
35
-
36
- export const bundleOptions = (flags: FlagValues<typeof bundleFlags>): BundleOptions => ({
37
- entry: flags.entry,
38
- publicUrl: flags["public-url"],
39
- remoteAssets: flags["remote-assets"],
40
- header: !flags["no-header"],
41
- // Both default on; `false` only when the flag is present, so the SDK's own defaults stand.
42
- minify: flags["no-minify"] ? false : undefined,
43
- sourcemap: flags["no-sourcemap"] ? false : undefined,
44
- })
45
-
46
- // ---- where / who -----------------------------------------------------------------------------
47
-
48
- export type Session = { apiUrl: string, token: string }
49
-
50
- /** The logged-in session (api origin + token) or a "run `lecodes login`" error. */
51
- export const session = (): Session => {
52
- const config = loadConfig()
53
- return { apiUrl: requireApiUrl(config), token: requireToken(config) }
54
- }
55
-
56
- export type LinkedProject = Session & { root: string, manifest: Manifest }
57
-
58
- /** A server-backed checkout: the project root (found upward from the cwd), its manifest and the session. */
59
- export const linkedProject = (): LinkedProject => {
60
- const root = findProjectRoot(process.cwd())
61
- return { root, manifest: readManifest(root), ...session() }
62
- }
63
-
64
- export type LocalProject = { root: string, manifest: Manifest | null, name: string }
65
-
66
- /** Any project: a linked checkout, else the current folder standing alone (no login needed). */
67
- export const localProject = (): LocalProject => {
68
- try {
69
- const root = findProjectRoot(process.cwd())
70
- const manifest = readManifest(root)
71
- return { root, manifest, name: manifest.name }
72
- } catch {
73
- const root = process.cwd()
74
- return { root, manifest: null, name: basename(root) }
75
- }
76
- }
77
-
78
- /** A folder name for a project: lower-case, dashes, never empty. */
79
- export const slugOf = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project"
1
+ import { basename } from "node:path"
2
+ import { bool, str, type FlagValues } from "../cli"
3
+ import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
4
+ import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
5
+
6
+ /*
7
+ * What several commands share: the flag groups (spread into a command's `flags:`) and the
8
+ * "where am I / who am I" lookups every platform command starts with.
9
+ */
10
+
11
+ // ---- flag groups -----------------------------------------------------------------------------
12
+
13
+ /** How the project is compiled — `compile`, `dev`, `render`, `test`, `desktop run`, `app sync` … */
14
+ export const bundleFlags = {
15
+ entry: str("compile from this entry instead of the detected one", { value: "<path>" }),
16
+ "public-url": str("resolve assets from this URL prefix (default: the API origin / the working tree)", { value: "<url>" }),
17
+ "remote-assets": bool("desktop hosts: load asset() resources from the server instead of the working tree"),
18
+ "no-header": bool("omit the metadata header"),
19
+ "no-minify": bool("keep the bundle readable (no compress / mangle)"),
20
+ "no-sourcemap": bool("don't generate / inline the source map"),
21
+ }
22
+
23
+ /** `-o` for commands whose result is a file. */
24
+ export const outFlag = { out: str("write to this file instead of stdout", { alias: "o", value: "<file>" }) }
25
+
26
+ /** The compiler's view of the bundle flags (see compile.ts `buildBundle`). */
27
+ export type BundleOptions = {
28
+ entry?: string
29
+ publicUrl?: string
30
+ remoteAssets?: boolean
31
+ header?: boolean
32
+ minify?: boolean
33
+ sourcemap?: boolean
34
+ }
35
+
36
+ export const bundleOptions = (flags: FlagValues<typeof bundleFlags>): BundleOptions => ({
37
+ entry: flags.entry,
38
+ publicUrl: flags["public-url"],
39
+ remoteAssets: flags["remote-assets"],
40
+ header: !flags["no-header"],
41
+ // Both default on; `false` only when the flag is present, so the SDK's own defaults stand.
42
+ minify: flags["no-minify"] ? false : undefined,
43
+ sourcemap: flags["no-sourcemap"] ? false : undefined,
44
+ })
45
+
46
+ // ---- where / who -----------------------------------------------------------------------------
47
+
48
+ export type Session = { apiUrl: string, token: string }
49
+
50
+ /** The logged-in session (api origin + token) or a "run `lecodes login`" error. */
51
+ export const session = (): Session => {
52
+ const config = loadConfig()
53
+ return { apiUrl: requireApiUrl(config), token: requireToken(config) }
54
+ }
55
+
56
+ export type LinkedProject = Session & { root: string, manifest: Manifest }
57
+
58
+ /** A server-backed checkout: the project root (found upward from the cwd), its manifest and the session. */
59
+ export const linkedProject = (): LinkedProject => {
60
+ const root = findProjectRoot(process.cwd())
61
+ return { root, manifest: readManifest(root), ...session() }
62
+ }
63
+
64
+ export type LocalProject = { root: string, manifest: Manifest | null, name: string }
65
+
66
+ /** Any project: a linked checkout, else the current folder standing alone (no login needed). */
67
+ export const localProject = (): LocalProject => {
68
+ try {
69
+ const root = findProjectRoot(process.cwd())
70
+ const manifest = readManifest(root)
71
+ return { root, manifest, name: manifest.name }
72
+ } catch {
73
+ const root = process.cwd()
74
+ return { root, manifest: null, name: basename(root) }
75
+ }
76
+ }
77
+
78
+ /** A folder name for a project: lower-case, dashes, never empty. */
79
+ export const slugOf = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project"
@@ -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
+ }