lecodes-assets 0.2.0 → 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 (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,95 @@
1
+ // Shading pass — the two material quirks that make an exported model render far brighter than the
2
+ // art it was painted from, plus the flat-colour (unlit) conversion stylised assets usually want.
3
+ //
4
+ // SELF-LIT. A PBR material's emissive is meant for the parts that GLOW. Several exporters (and
5
+ // Sketchfab's own download path) instead wire the BASE COLOUR texture into `emissiveTexture` with
6
+ // `emissiveFactor = [1,1,1]`, so the shader adds a full copy of the albedo on top of the lit result:
7
+ //
8
+ // out = lit(baseColor) + baseColor instead of out = lit(baseColor)
9
+ //
10
+ // Under any decent IBL that is ~2× too bright — the model looks washed out and loses its shading,
11
+ // and the usual first suspects (metallic, the texture itself) are innocent.
12
+ //
13
+ // SPECULAR BOOST. `KHR_materials_specular` with a `specularColorFactor` above 1 is the same story
14
+ // for the highlight. gltfio ALSO refuses to combine it with unlit ("Cannot use specular extension
15
+ // with unlit"), so an unlit conversion must drop it or the shading model silently reverts to lit.
16
+ import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
17
+ import type { Document, Material } from '@gltf-transform/core'
18
+
19
+ const DROP_ON_UNLIT = [
20
+ 'KHR_materials_specular', 'KHR_materials_ior', 'KHR_materials_sheen', 'KHR_materials_clearcoat',
21
+ 'KHR_materials_transmission', 'KHR_materials_volume', 'KHR_materials_iridescence',
22
+ 'KHR_materials_anisotropy', 'KHR_materials_dispersion',
23
+ ]
24
+
25
+ export interface ShadingIssue {
26
+ mat: Material
27
+ name: string
28
+ /** emissive is a full copy of the base colour — the model renders ~2× too bright */
29
+ selfLit: boolean
30
+ /** highest component of a `KHR_materials_specular` colour factor above 1, else 0 */
31
+ specularBoost: number
32
+ /** already flat-shaded via KHR_materials_unlit */
33
+ unlit: boolean
34
+ }
35
+ export interface ShadingPlan { issues: ShadingIssue[]; unlit: boolean; fixEmissive: boolean }
36
+ export interface ShadingOptions { unlit?: boolean; fixEmissive?: boolean }
37
+
38
+ const specularOf = (m: Material): number => {
39
+ const spec = m.getExtension('KHR_materials_specular') as unknown as { getSpecularColorFactor?: () => number[] } | null
40
+ const f = spec?.getSpecularColorFactor?.() ?? []
41
+ return Math.max(0, ...f.map((v) => (v > 1 ? v : 0)))
42
+ }
43
+
44
+ export function analyzeShading(doc: Document, opts: ShadingOptions = {}): ShadingPlan {
45
+ const issues: ShadingIssue[] = []
46
+ for (const mat of doc.getRoot().listMaterials()) {
47
+ const emissive = mat.getEmissiveFactor()
48
+ const emTex = mat.getEmissiveTexture()
49
+ // "A full copy" means the emissive channel carries the albedo at (near) full strength: either
50
+ // literally the same Texture object, or — for untextured materials — a white emissive factor.
51
+ const strong = Math.max(...emissive) >= 0.5
52
+ const selfLit = strong && (emTex ? emTex === mat.getBaseColorTexture() : true)
53
+ const specularBoost = specularOf(mat)
54
+ const unlit = mat.listExtensions().some((e) => e.extensionName === 'KHR_materials_unlit')
55
+ if (selfLit || specularBoost > 0 || (opts.unlit && !unlit)) {
56
+ issues.push({ mat, name: mat.getName() || '?', selfLit, specularBoost, unlit })
57
+ }
58
+ }
59
+ return { issues, unlit: !!opts.unlit, fixEmissive: !!opts.fixEmissive }
60
+ }
61
+
62
+ export interface ShadingResult { unlit: number; emissiveCleared: number; extensionsDropped: number }
63
+
64
+ export function applyShading(doc: Document, plan: ShadingPlan): ShadingResult {
65
+ const res: ShadingResult = { unlit: 0, emissiveCleared: 0, extensionsDropped: 0 }
66
+ if (!plan.unlit && !plan.fixEmissive) return res
67
+ const unlitExt = plan.unlit ? doc.createExtension(KHRMaterialsUnlit) : null
68
+ for (const { mat, selfLit, unlit } of plan.issues) {
69
+ // Unlit shades baseColorFactor × baseColorTexture and nothing else, so the emissive copy has to
70
+ // go either way — left in place a lit fallback would still double the albedo.
71
+ if (plan.unlit || (plan.fixEmissive && selfLit)) {
72
+ if (Math.max(...mat.getEmissiveFactor()) > 0 || mat.getEmissiveTexture()) {
73
+ mat.setEmissiveFactor([ 0, 0, 0 ]).setEmissiveTexture(null)
74
+ res.emissiveCleared++
75
+ }
76
+ }
77
+ if (!plan.unlit) continue
78
+ if (!unlit) { mat.setExtension('KHR_materials_unlit', unlitExt!.createUnlit()); res.unlit++ }
79
+ for (const name of DROP_ON_UNLIT) {
80
+ if (mat.getExtension(name)) { mat.setExtension(name, null); res.extensionsDropped++ }
81
+ }
82
+ // Inputs an unlit shader never reads: keep the file honest so a lit viewer that ignores the
83
+ // extension still renders the flat colour rather than a stray highlight.
84
+ mat.setMetallicFactor(0).setRoughnessFactor(1)
85
+ .setMetallicRoughnessTexture(null).setNormalTexture(null).setOcclusionTexture(null)
86
+ }
87
+ // Unlinking the property is not enough: the Extension stays registered on the document and keeps
88
+ // writing its name into `extensionsUsed`. Drop it once no material references it at all.
89
+ const materials = doc.getRoot().listMaterials()
90
+ for (const ext of doc.getRoot().listExtensionsUsed()) {
91
+ if (!DROP_ON_UNLIT.includes(ext.extensionName)) continue
92
+ if (!materials.some((m) => m.getExtension(ext.extensionName))) ext.dispose()
93
+ }
94
+ return res
95
+ }
@@ -0,0 +1,275 @@
1
+ // KTX2 / Basis-Universal encoding via KTX-Software's `toktx` (filament supports image/ktx2 through
2
+ // createKtx2Provider — GPU-compressed, supports alpha, smaller on disk AND in VRAM).
3
+ //
4
+ // Codec by texture role:
5
+ // - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
6
+ // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise; ETC1S steps on a smooth channel)
7
+ // …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
8
+ // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
9
+ // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
10
+ // trade there and the size difference is roughly 8x.
11
+ // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
12
+ import { createHash } from 'node:crypto'
13
+ import sharp from 'sharp'
14
+ import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
15
+ import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
16
+ import { cpus, tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import type { Root, Texture } from '@gltf-transform/core'
19
+ import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
20
+
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)
38
+ }
39
+ return [ '/usr/local/bin', '/opt/homebrew/bin' ]
40
+ }
41
+
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 {
58
+ try {
59
+ const p = spawnSync(tool, ['--version'], { encoding: 'utf8' })
60
+ if (p.status === 0) return (p.stdout ?? '').toString().trim() || tool
61
+ } catch { /* not found */ }
62
+ return null
63
+ }
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
+
105
+ export interface Ktx2Plan {
106
+ tex: Texture
107
+ name: string
108
+ roles: string
109
+ fromFmt: string
110
+ codec: 'etc1s' | 'uastc'
111
+ oetf: 'srgb' | 'linear'
112
+ resize: boolean
113
+ width: number
114
+ height: number
115
+ hasAlpha: boolean
116
+ beforeBytes: number
117
+ afterBytes?: number
118
+ category: 'lossy'
119
+ reason: string
120
+ }
121
+
122
+ export type CodecPolicy = 'default' | 'hq' | 'uastc'
123
+
124
+ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, policy: CodecPolicy = 'hq'): Promise<Ktx2Plan | null> {
125
+ const img = tex.getImage()
126
+ const mime = tex.getMimeType() || ''
127
+ const fromFmt = mime.replace('image/', '') || '?'
128
+ if (!img || fromFmt.includes('ktx')) return null // missing or already ktx2
129
+
130
+ const { roles, isNormal, isData } = getTextureRoles(tex, root)
131
+ let width = 0, height = 0, hasAlpha = false
132
+ try {
133
+ const b = Buffer.from(img)
134
+ const m = await sharp(b).metadata()
135
+ width = m.width || 0; height = m.height || 0
136
+ hasAlpha = !(await sharp(b).stats()).isOpaque
137
+ } catch {
138
+ return null
139
+ }
140
+
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.
145
+ // oetf: linear for any non-colour data.
146
+ const codec: 'etc1s' | 'uastc' =
147
+ policy === 'uastc' ? 'uastc'
148
+ : isNormal || isData ? 'uastc'
149
+ : 'etc1s'
150
+ const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
151
+ const resize = Math.max(width, height) > maxSize
152
+ return {
153
+ tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
154
+ beforeBytes: img.length, category: 'lossy',
155
+ reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}`,
156
+ }
157
+ }
158
+
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> {
243
+ const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
244
+ const inPng = join(dir, 'in.png')
245
+ const outKtx = join(dir, 'out.ktx2')
246
+ try {
247
+ // GPU block-compressed formats (BC7/BC3/ETC2, 4×4 blocks) require the texture's base dimensions
248
+ // to be MULTIPLES OF 4 — otherwise filament's glTexStorage2D fails ("Invalid compressed image
249
+ // size") and the texture renders broken. Snap dims to ×4 (and apply the maxSize cap) before toktx.
250
+ const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
251
+ let tw = plan.width, th = plan.height
252
+ const m = Math.max(tw, th)
253
+ if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
254
+ tw = snap4(tw); th = snap4(th)
255
+
256
+ let pipe = sharp(Buffer.from(plan.tex.getImage()!))
257
+ if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
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
261
+ writeFileSync(inPng, await pipe.png().toBuffer())
262
+
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
+ })
267
+
268
+ await acquire()
269
+ try { await runEncoder(enc, args, plan.name) } finally { release() }
270
+
271
+ return new Uint8Array(readFileSync(outKtx))
272
+ } finally {
273
+ rmSync(dir, { recursive: true, force: true })
274
+ }
275
+ }
@@ -0,0 +1,101 @@
1
+ // Texture normalization for creator-gl (filament providers = png/jpeg/ktx2 only) + size budget.
2
+ // Split into PLAN (analysis, no mutation) and APPLY (sharp re-encode).
3
+ //
4
+ // Policy (format chosen purely by alpha — PNG only when alpha is needed, else JPG, to keep size
5
+ // small; webp→png for a 2048 image is ~5–10× larger than webp, so we avoid PNG unless required):
6
+ // - alpha present -> PNG (jpeg has no alpha channel)
7
+ // - no alpha -> JPG (normals/metalRough use quality 92 + 4:4:4 chroma)
8
+ // - webp -> re-encode per the rule above (filament has no webp provider)
9
+ // - max dim > 2048 -> downscale to fit 2048 + re-encode
10
+ // - opaque PNG (>=1024px) -> JPG (heavy opaque PNGs are wasteful)
11
+ // - ktx2 -> left untouched (supported; sharp can't handle it)
12
+ import sharp from 'sharp'
13
+ import type { Root, Texture } from '@gltf-transform/core'
14
+
15
+ export const DEFAULT_MAX_SIZE = 2048
16
+ const HEAVY_PNG_MIN_DIM = 1024
17
+
18
+ export interface TexturePlan {
19
+ tex: Texture
20
+ name: string
21
+ roles: string
22
+ fromFmt: string
23
+ toFmt: 'jpeg' | 'png' | null // null = leave unchanged
24
+ resize: boolean
25
+ width: number
26
+ height: number
27
+ hasAlpha: boolean
28
+ isNormal: boolean
29
+ isData: boolean
30
+ category: 'lossless' | 'lossy' | null
31
+ reason: string
32
+ beforeBytes: number
33
+ afterBytes?: number
34
+ }
35
+
36
+ export function getTextureRoles(tex: Texture, root: Root) {
37
+ const set = new Set<string>()
38
+ for (const m of root.listMaterials()) {
39
+ if (m.getBaseColorTexture() === tex) set.add('base')
40
+ if (m.getEmissiveTexture() === tex) set.add('emissive')
41
+ if (m.getNormalTexture() === tex) set.add('normal')
42
+ if (m.getMetallicRoughnessTexture() === tex) set.add('metalRough')
43
+ if (m.getOcclusionTexture() === tex) set.add('occlusion')
44
+ }
45
+ return { roles: [...set].join('+') || 'unused', isNormal: set.has('normal'), isData: set.has('metalRough') || set.has('occlusion') }
46
+ }
47
+
48
+ export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, forceFmt?: 'jpeg' | 'png'): Promise<TexturePlan | null> {
49
+ const img = tex.getImage()
50
+ const mime = tex.getMimeType() || ''
51
+ const fromFmt = mime.replace('image/', '') || '?'
52
+ if (!img) return null
53
+ if (fromFmt.includes('ktx')) return null
54
+
55
+ const { roles, isNormal, isData } = getTextureRoles(tex, root)
56
+ let width = 0, height = 0, hasAlpha = false
57
+ try {
58
+ const buf = Buffer.from(img)
59
+ const meta = await sharp(buf).metadata()
60
+ width = meta.width || 0; height = meta.height || 0
61
+ hasAlpha = !(await sharp(buf).stats()).isOpaque
62
+ } catch {
63
+ return null // undecodable by sharp — leave it
64
+ }
65
+
66
+ const resize = Math.max(width, height) > maxSize
67
+ let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
68
+ if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
69
+
70
+ let act = false, reason = 'ok'
71
+ if (forceFmt) {
72
+ if ((fromFmt as string) !== desired || resize || (fromFmt as string) === 'webp') { act = true; reason = `${fromFmt} → ${desired}${resize ? ` (≤${maxSize}px)` : ''}` }
73
+ }
74
+ else if (fromFmt === 'webp') { act = true; reason = `webp → ${desired}` }
75
+ else if (resize) { act = true; reason = `${width}×${height} → ≤${maxSize}px, ${desired}` }
76
+ else if (fromFmt === 'png' && desired === 'jpeg' && Math.max(width, height) >= HEAVY_PNG_MIN_DIM) { act = true; reason = `heavy opaque png → jpeg` }
77
+
78
+ const base = { tex, name: tex.getName() || '?', roles, fromFmt, resize, width, height, hasAlpha, isNormal, isData, beforeBytes: img.length }
79
+ if (!act) return { ...base, toFmt: null, category: null, reason }
80
+
81
+ // lossless only when staying/becoming PNG without a resize (no further pixel loss)
82
+ const category: 'lossless' | 'lossy' = desired === 'png' && !resize ? 'lossless' : 'lossy'
83
+ return { ...base, toFmt: desired, category, reason }
84
+ }
85
+
86
+ export async function applyTexturePlan(plan: TexturePlan, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
87
+ if (!plan.toFmt) return
88
+ let pipe = sharp(Buffer.from(plan.tex.getImage()!))
89
+ if (plan.resize) pipe = pipe.resize({ width: maxSize, height: maxSize, fit: 'inside', withoutEnlargement: true })
90
+ if (plan.toFmt === 'jpeg') {
91
+ // normals / metal-rough / occlusion carry data, not colour: higher quality, no chroma subsampling
92
+ const dataLike = plan.isNormal || plan.isData
93
+ const out = await pipe.jpeg({ quality: dataLike ? 92 : 85, mozjpeg: true, chromaSubsampling: dataLike ? '4:4:4' : '4:2:0' }).toBuffer()
94
+ plan.tex.setImage(new Uint8Array(out)).setMimeType('image/jpeg')
95
+ plan.afterBytes = out.length
96
+ } else {
97
+ const out = await pipe.png({ compressionLevel: 9 }).toBuffer()
98
+ plan.tex.setImage(new Uint8Array(out)).setMimeType('image/png')
99
+ plan.afterBytes = out.length
100
+ }
101
+ }