lecodes-assets 0.2.1 → 2.4.10

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.
Files changed (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +67 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +103 -0
  25. package/src/commands/shared.ts +121 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,77 @@
1
+ // The merge pass's vocabulary: options, the plan (what WOULD be merged — the doctor reports it), the result.
2
+
3
+ import type { Material, Mesh, Node, Primitive, Skin, Texture } from '@gltf-transform/core'
4
+
5
+ export interface MergeOptions {
6
+ /** Merge every mergeable primitive into one per material class + atlas the textures (see the header). */
7
+ merge?: boolean
8
+ /** Largest atlas edge in texels (default 2048); tiles scale down uniformly to fit. */
9
+ atlasSize?: number
10
+ /** Edge-replication padding around every tile, in texels (default 8). */
11
+ atlasPad?: number
12
+ /** Node names (regex) to leave out of the merge — a part the game hides or swaps. */
13
+ mergeExclude?: string
14
+ /** Scale every tile by the same factor to fit (default: by surface area — equal texel density). */
15
+ atlasUniform?: boolean
16
+ }
17
+
18
+ export const DEFAULT_ATLAS_SIZE = 2048
19
+ export const DEFAULT_ATLAS_PAD = 8
20
+
21
+ export type Mat4 = number[]
22
+ export type Kind = 'skinned' | 'rigid' | 'static'
23
+ export type MapKind = 'base' | 'normal' | 'orm' | 'emissive'
24
+
25
+ export interface Source { node: Node; mesh: Mesh; prim: Primitive; kind: Kind; skin: Skin | null; joint: Node | null; name: string; material: Material | null; triangles: number }
26
+ export interface MergeSkipped { name: string; reason: string }
27
+
28
+ export interface Tile {
29
+ key: string
30
+ materials: Material[]
31
+ /** source texel size (the material's largest map, × the UV repeat) */
32
+ srcW: number; srcH: number
33
+ /** UV repeat and origin: the tile covers [u0, u0 + ru) × [v0, v0 + rv) of the material's UV space */
34
+ u0: number; v0: number; ru: number; rv: number
35
+ /** surface on the model (m², bind / world space) and the fraction of the tile's UV square the triangles cover */
36
+ area: number; uvArea: number
37
+ /** placed size and position (inner rect, without the padding) at the chosen scale */
38
+ w: number; h: number; x: number; y: number
39
+ /** tile edge / source edge */
40
+ scale: number
41
+ }
42
+ export interface AtlasLayout { size: number; /** smallest tile scale */ scale: number; /** largest tile scale */ scaleMax: number; byArea: boolean; pad: number; maps: MapKind[]; tiles: Tile[]; hasAlpha: boolean }
43
+
44
+ export interface MergeGroupPlan {
45
+ label: string
46
+ family: 'skinned' | 'static'
47
+ sources: Source[]
48
+ materials: Material[]
49
+ /** null = the group keeps its (single) material — geometry-only merge */
50
+ atlas: AtlasLayout | null
51
+ /** the class the merged material inherits */
52
+ alphaMode: 'OPAQUE' | 'MASK' | 'BLEND'; alphaCutoff: number; doubleSided: boolean; unlit: boolean
53
+ joints: number
54
+ note?: string
55
+ }
56
+ export interface MergePlan {
57
+ enabled: boolean
58
+ atlasSize: number
59
+ pad: number
60
+ groups: MergeGroupPlan[]
61
+ skipped: MergeSkipped[]
62
+ primitivesBefore: number
63
+ materialsBefore: number
64
+ /** primitives that stay (skipped) + one per group */
65
+ primitivesAfter: number
66
+ /** joints of the largest merged skin (the bone cap applies to it) */
67
+ joints: number
68
+ ktxTool: string | null
69
+ }
70
+ export interface MergeResult {
71
+ groups: { label: string; primitives: number; materials: number; vertices: number; triangles: number; atlas: string | null; joints: number }[]
72
+ skipped: MergeSkipped[]
73
+ /** the atlas textures created (for the caller's texture passes) */
74
+ textures: Texture[]
75
+ primitivesBefore: number
76
+ primitivesAfter: number
77
+ }
@@ -3,40 +3,105 @@
3
3
  //
4
4
  // Codec by texture role:
5
5
  // - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
6
- // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise)
6
+ // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise; ETC1S steps on a smooth channel)
7
7
  // …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
8
8
  // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
9
9
  // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
10
10
  // trade there and the size difference is roughly 8x.
11
11
  // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
12
+ import { createHash } from 'node:crypto'
12
13
  import sharp from 'sharp'
13
- import { spawnSync } from 'node:child_process'
14
+ import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
14
15
  import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
15
- import { tmpdir } from 'node:os'
16
+ import { cpus, tmpdir } from 'node:os'
16
17
  import { join } from 'node:path'
17
18
  import type { Root, Texture } from '@gltf-transform/core'
18
19
  import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
19
20
 
20
- /** Locate the toktx binary: $KTX_TOKTX → %LOCALAPPDATA%\Programs\KTX-Software → PATH. */
21
- export function findToktx(): string {
22
- const env = process.env.KTX_TOKTX
23
- if (env && existsSync(env)) return env
24
- const la = process.env.LOCALAPPDATA
25
- if (la) {
26
- const guess = join(la, 'Programs', 'KTX-Software', 'bin', 'toktx.exe')
27
- if (existsSync(guess)) return guess
21
+ /**
22
+ * The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
23
+ * tool's `create` subcommand. KTX-Software 5 dropped `toktx` (the macOS installer ships only `ktx`),
24
+ * 4.4 still has both; the two write the same Basis files from the same inputs, only the flags differ
25
+ * (`ktxEncodeArgs`).
26
+ */
27
+ export interface KtxEncoder { kind: 'toktx' | 'ktx'; path: string; version: string }
28
+
29
+ export const KTX_ENCODER_MISSING =
30
+ 'KTX-Software not found — neither `toktx` nor `ktx` (looked at $KTX_TOKTX, $KTX_TOOL, the installer\'s folders, PATH).\n' +
31
+ ' Install it from https://github.com/KhronosGroup/KTX-Software/releases, or set KTX_TOKTX / KTX_TOOL=<path to the binary>.'
32
+
33
+ /** Where KTX-Software's installers put their binaries, per platform. */
34
+ function installDirs(): string[] {
35
+ if (process.platform === 'win32') {
36
+ return [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'KTX-Software', 'bin'),
37
+ process.env.ProgramFiles && join(process.env.ProgramFiles, 'KTX-Software', 'bin') ].filter((d): d is string => !!d)
28
38
  }
29
- return 'toktx' // assume on PATH
39
+ return [ '/usr/local/bin', '/opt/homebrew/bin' ]
30
40
  }
31
41
 
32
- export function checkToktx(toktx: string): string | null {
42
+ /** `name` resolved: the env override, then the installer's folders, then PATH (the bare name). */
43
+ function locate(name: 'toktx' | 'ktx', env: string | undefined): string {
44
+ if (env && existsSync(env)) return env
45
+ const exe = process.platform === 'win32' ? `${name}.exe` : name
46
+ return installDirs().map((d) => join(d, exe)).find((p) => existsSync(p)) ?? exe
47
+ }
48
+
49
+ /** Locate the toktx binary: $KTX_TOKTX → the installer's folders → PATH. */
50
+ export const findToktx = (): string => locate('toktx', process.env.KTX_TOKTX)
51
+
52
+ /** Locate KTX-Software's unified `ktx` tool: $KTX_TOOL → next to $KTX_TOKTX → the installer's folders → PATH. */
53
+ export const findKtxCli = (): string =>
54
+ locate('ktx', process.env.KTX_TOOL ?? process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, process.platform === 'win32' ? 'ktx.exe' : 'ktx'))
55
+
56
+ /** The binary's `--version` line, or null when it does not run. */
57
+ export function checkToktx(tool: string): string | null {
33
58
  try {
34
- const p = spawnSync(toktx, ['--version'], { encoding: 'utf8' })
35
- if (p.status === 0) return (p.stdout ?? '').toString().trim() || 'toktx'
59
+ const p = spawnSync(tool, ['--version'], { encoding: 'utf8' })
60
+ if (p.status === 0) return (p.stdout ?? '').toString().trim() || tool
36
61
  } catch { /* not found */ }
37
62
  return null
38
63
  }
39
64
 
65
+ /** toktx when it runs, else `ktx` (create), else null. */
66
+ export function findKtxEncoder(): KtxEncoder | null {
67
+ const toktx = findToktx()
68
+ const tv = checkToktx(toktx)
69
+ if (tv) return { kind: 'toktx', path: toktx, version: tv }
70
+ const ktx = findKtxCli()
71
+ const kv = checkToktx(ktx)
72
+ return kv ? { kind: 'ktx', path: ktx, version: kv } : null
73
+ }
74
+
75
+ export interface KtxEncodeJob {
76
+ input: string
77
+ output: string
78
+ codec: 'etc1s' | 'uastc'
79
+ oetf: 'srgb' | 'linear'
80
+ /** Channels of the input PNG — `ktx create` wants the format spelled out. */
81
+ channels: 3 | 4
82
+ /** UASTC quality 0–4. */
83
+ uastcQuality: number
84
+ /** Zstandard level for UASTC (ETC1S is Basis-LZ, never supercompressed on top). */
85
+ zstd: number
86
+ }
87
+
88
+ /** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned). */
89
+ export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
90
+ if (enc.kind === 'toktx') {
91
+ return [ '--t2', ...(j.codec === 'etc1s'
92
+ ? [ '--encode', 'etc1s', '--clevel', '2', '--qlevel', '128' ]
93
+ : [ '--encode', 'uastc', '--uastc_quality', String(j.uastcQuality), '--zcmp', String(j.zstd) ]),
94
+ '--genmipmap', '--assign_oetf', j.oetf, j.output, j.input ]
95
+ }
96
+ const format = `${j.channels === 4 ? 'R8G8B8A8' : 'R8G8B8'}_${j.oetf === 'srgb' ? 'SRGB' : 'UNORM'}`
97
+ return [ 'create', '--format', format, ...(j.codec === 'etc1s'
98
+ ? [ '--encode', 'basis-lz', '--clevel', '2', '--qlevel', '128' ]
99
+ : [ '--encode', 'uastc', '--uastc-quality', String(j.uastcQuality), '--zstd', String(j.zstd) ]),
100
+ // --assign-tf: sharp's PNGs carry no colour chunk, which `ktx` reads as sRGB and then refuses to
101
+ // put into a *_UNORM format without a conversion — the data maps ARE linear, so say so.
102
+ '--generate-mipmap', '--assign-tf', j.oetf, j.input, j.output ]
103
+ }
104
+
40
105
  export interface Ktx2Plan {
41
106
  tex: Texture
42
107
  name: string
@@ -73,12 +138,14 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
73
138
  return null
74
139
  }
75
140
 
76
- // Normally only normal maps justify UASTC (high quality, ~8bpp): colour and metalRough/occlusion
77
- // compress far smaller with ETC1S at acceptable quality. 'uastc' overrides that for hero assets.
141
+ // DATA maps (normal, metal / rough, occlusion) always take UASTC: ETC1S is a palette codec whose blocks show
142
+ // as steps on a smooth metallic or roughness channel (the range's metal props read pixelated under the
143
+ // reflection probes, 2026-09-17) and it wrecks normals; colour stays on ETC1S (small, its artefacts hide in the
144
+ // detail) unless 'uastc' asks for UASTC everywhere (hero assets). 'hq' is the same as the default now.
78
145
  // oetf: linear for any non-colour data.
79
146
  const codec: 'etc1s' | 'uastc' =
80
147
  policy === 'uastc' ? 'uastc'
81
- : isNormal ? (policy === 'hq' ? 'uastc' : 'etc1s')
148
+ : isNormal || isData ? 'uastc'
82
149
  : 'etc1s'
83
150
  const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
84
151
  const resize = Math.max(width, height) > maxSize
@@ -89,7 +156,90 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
89
156
  }
90
157
  }
91
158
 
92
- export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
159
+ /**
160
+ * ENCODED KTX2 BY CONTENT, for the life of the process.
161
+ *
162
+ * Encoding is pure: the same pixels at the same cap, codec and transfer function give the same file.
163
+ * And a LEVEL import asks for the same pixels over and over — every kind is its own self-contained
164
+ * GLB, so a wall texture shared by a hundred buildings is embedded, and therefore encoded, a hundred
165
+ * times. Measured on the Chernobyl import: **3554 embedded textures of which 314 are distinct**, i.e.
166
+ * 11.3 encodes of each on average and 144 of the most-shared ones. Each of those is a temp dir, a
167
+ * sharp decode + PNG re-encode, and a SYNCHRONOUS `toktx` process — which is also why the importer
168
+ * sat at 1.4 of this machine's 24 cores while it ran.
169
+ *
170
+ * Keyed on everything that changes the output and nothing else.
171
+ */
172
+ const encoded = new Map<string, Uint8Array>()
173
+ let hits = 0
174
+ /** Encodes RUNNING right now, by cache key: a second caller for the same image waits on the first
175
+ * instead of starting its own toktx. Without it the concurrency below would turn the cache's whole
176
+ * point inside out — a texture on a hundred props would encode a hundred times, in parallel. */
177
+ const inFlight = new Map<string, Promise<Uint8Array>>()
178
+
179
+ /**
180
+ * How many toktx processes may run at once. The encode is an external process per texture, so the
181
+ * work is ENTIRELY outside this thread and the only reason to cap it is not to swamp the machine;
182
+ * one per core, minus one for us, is the usual shape. `LECODES_KTX_JOBS` overrides it (1 = the old
183
+ * strictly-sequential behaviour, for a bisect).
184
+ */
185
+ const JOBS = Math.max(1, Number(process.env.LECODES_KTX_JOBS) || Math.max(1, cpus().length - 1))
186
+ let running = 0
187
+ const waiting: (() => void)[] = []
188
+ const acquire = async (): Promise<void> => {
189
+ if (running < JOBS) { running++; return }
190
+ await new Promise<void>((res) => waiting.push(res))
191
+ running++
192
+ }
193
+ const release = (): void => { running--; waiting.shift()?.() }
194
+
195
+ export const ktx2Jobs = (): number => JOBS
196
+
197
+ /** The encoder as a promise. `spawnSync` blocked the event loop for the whole encode, which made every
198
+ * texture in a project strictly sequential no matter how many cores were idle. */
199
+ const runEncoder = (enc: KtxEncoder, args: string[], name: string): Promise<void> =>
200
+ new Promise((resolve, reject) => {
201
+ const proc = spawn(enc.path, args, { windowsHide: true })
202
+ let err = ''
203
+ proc.stderr?.on('data', (d) => { err += String(d) })
204
+ proc.on('error', reject)
205
+ proc.on('close', (code) => code === 0
206
+ ? resolve()
207
+ : reject(new Error(`${enc.kind === 'ktx' ? 'ktx create' : 'toktx'} failed (exit ${code}) for "${name}": ${err.slice(0, 300)}`)))
208
+ })
209
+ /** How many encodes this process served from the cache (for a caller that wants to report it). */
210
+ export const ktx2CacheHits = (): number => hits
211
+
212
+ export async function applyKtx2(plan: Ktx2Plan, enc: KtxEncoder, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
213
+ const src = plan.tex.getImage()
214
+ const key = src
215
+ ? `${createHash('sha1').update(src).digest('hex')}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}`
216
+ : null
217
+ if (key) {
218
+ const hit = encoded.get(key)
219
+ if (hit) { hits++; plan.tex.setImage(hit).setMimeType('image/ktx2'); plan.afterBytes = hit.length; return }
220
+ // the same image, already being encoded by another caller: wait on that one
221
+ const busy = inFlight.get(key)
222
+ if (busy) {
223
+ hits++
224
+ const bytes = await busy
225
+ plan.tex.setImage(bytes).setMimeType('image/ktx2')
226
+ plan.afterBytes = bytes.length
227
+ return
228
+ }
229
+ }
230
+ const work = encodeOne(plan, enc, maxSize)
231
+ if (key) {
232
+ inFlight.set(key, work)
233
+ try { encoded.set(key, await work) } finally { inFlight.delete(key) }
234
+ }
235
+ const bytes = await work
236
+ plan.tex.setImage(bytes).setMimeType('image/ktx2')
237
+ plan.afterBytes = bytes.length
238
+ }
239
+
240
+ /** One texture through sharp + the encoder, returning the encoded bytes. Holds a pool slot only around
241
+ * the external process, so the sharp resize of the next texture overlaps with this one's encode. */
242
+ async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Promise<Uint8Array> {
93
243
  const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
94
244
  const inPng = join(dir, 'in.png')
95
245
  const outKtx = join(dir, 'out.ktx2')
@@ -105,19 +255,20 @@ export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT
105
255
 
106
256
  let pipe = sharp(Buffer.from(plan.tex.getImage()!))
107
257
  if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
108
- if (!plan.hasAlpha) pipe = pipe.removeAlpha() // RGB → no wasted alpha slice
258
+ // Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
259
+ pipe = pipe.toColourspace('srgb')
260
+ pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
109
261
  writeFileSync(inPng, await pipe.png().toBuffer())
110
262
 
111
- const args = plan.codec === 'etc1s'
112
- ? ['--encode', 'etc1s', '--clevel', '2', '--qlevel', '128', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
113
- : ['--encode', 'uastc', '--uastc_quality', '2', '--zcmp', '18', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
263
+ const args = ktxEncodeArgs(enc, {
264
+ input: inPng, output: outKtx, codec: plan.codec, oetf: plan.oetf,
265
+ channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18,
266
+ })
114
267
 
115
- const proc = spawnSync(toktx, args, { encoding: 'utf8' })
116
- if (proc.status !== 0) throw new Error(`toktx failed (exit ${proc.status}) for "${plan.name}": ${(proc.stderr ?? '').toString().slice(0, 300)}`)
268
+ await acquire()
269
+ try { await runEncoder(enc, args, plan.name) } finally { release() }
117
270
 
118
- const ktx = readFileSync(outKtx)
119
- plan.tex.setImage(new Uint8Array(ktx)).setMimeType('image/ktx2')
120
- plan.afterBytes = ktx.length
271
+ return new Uint8Array(readFileSync(outKtx))
121
272
  } finally {
122
273
  rmSync(dir, { recursive: true, force: true })
123
274
  }
@@ -1,4 +1,4 @@
1
- // MIRROR of packages/viewer/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
1
+ // MIRROR of runtime/web/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
2
2
  // duplicated so lecodes-assets stays publishable without a cross-package import).
3
3
  // Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
4
4
  //
@@ -17,7 +17,7 @@
17
17
  export const MAX_BONE_COUNT = 256
18
18
  export const MAX_MORPH_TARGET_COUNT = 256
19
19
 
20
- // Texture formats with a provider registered in creator-gl (see packages/creator-gl/src/glb.cpp).
20
+ // Texture formats with a provider registered in creator-gl (see engines/gl/src/glb.cpp).
21
21
  export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
22
22
  export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
23
23