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.
- package/README.md +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/transcodeKtx2.ts +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +325 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- 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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 '
|
|
39
|
+
return [ '/usr/local/bin', '/opt/homebrew/bin' ]
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
|
|
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(
|
|
35
|
-
if (p.status === 0) return (p.stdout ?? '').toString().trim() ||
|
|
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
|
-
//
|
|
77
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
112
|
-
|
|
113
|
-
:
|
|
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
|
-
|
|
116
|
-
|
|
268
|
+
await acquire()
|
|
269
|
+
try { await runEncoder(enc, args, plan.name) } finally { release() }
|
|
117
270
|
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
|