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.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- 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 -0
- 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 +305 -0
- package/src/doctor/lod.ts +177 -0
- 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/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- 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 +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- 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 +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
package/src/sky/index.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Equirectangular HDR (.exr / .hdr) → the two cubemaps a LeCodes scene needs.
|
|
2
|
+
//
|
|
3
|
+
// The thing everyone gets wrong, so it is worth stating plainly: an environment is TWO files and
|
|
4
|
+
// they are NOT interchangeable.
|
|
5
|
+
//
|
|
6
|
+
// sky.ktx — sharp, single mip. What is DRAWN behind the scene (`skybox: { texture }`).
|
|
7
|
+
// ibl.ktx — prefiltered per roughness + spherical harmonics. What LIGHTS the scene.
|
|
8
|
+
//
|
|
9
|
+
// Handing the IBL to the skybox is the tempting shortcut (one file, and it "works") but that file
|
|
10
|
+
// has been blurred on purpose, so the sky comes out visibly soft. cmgen emits both for this reason;
|
|
11
|
+
// this command just makes the pair reproducible and names them the way the runtime expects.
|
|
12
|
+
//
|
|
13
|
+
// The IBL is deliberately smaller: it is only ever sampled as low-frequency lighting, so 256 is
|
|
14
|
+
// plenty, while the sky is looked at directly and wants 512+.
|
|
15
|
+
import { spawnSync } from "node:child_process"
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
|
|
17
|
+
import { tmpdir } from "node:os"
|
|
18
|
+
import { basename, extname, join, resolve } from "node:path"
|
|
19
|
+
import { findCmgen, checkCmgen, CMGEN_HELP } from "./cmgen"
|
|
20
|
+
import { analyzeSkyKtx, type SkyAnalysis } from "./analyze"
|
|
21
|
+
|
|
22
|
+
export type SkyOptions = {
|
|
23
|
+
/** Where the pair lands (default: beside the input). */
|
|
24
|
+
outDir?: string
|
|
25
|
+
/** Cubemap edge for the drawn sky (default 512). */
|
|
26
|
+
size?: number
|
|
27
|
+
/** Cubemap edge for the lighting probe (default 256). */
|
|
28
|
+
iblSize?: number
|
|
29
|
+
/** File names, without extension (default "sky" and "ibl" — what the runtime looks for). */
|
|
30
|
+
skyName?: string
|
|
31
|
+
iblName?: string
|
|
32
|
+
/** Skip the sky, emit only the lighting probe. */
|
|
33
|
+
iblOnly?: boolean
|
|
34
|
+
/** Take the SUN out of the lighting probe (cmgen --clamp-sun, the LeCodes fork's): for a level that has a directional
|
|
35
|
+
* sun. The sun in the probe is the same light a second time - from everywhere, without shadows: it flattens every
|
|
36
|
+
* object and halves the contrast of the baked shade. The drawn sky keeps its sun. `true` = 8 degrees. */
|
|
37
|
+
noSun?: boolean | number
|
|
38
|
+
log?: (msg: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What `noSun` measured: the disc taken out of the probe, and what the scene should be given for it. */
|
|
42
|
+
export type SunCalibration = {
|
|
43
|
+
/** The disc's illuminance on a surface facing it, in the picture's own units. */
|
|
44
|
+
discUnits: number
|
|
45
|
+
/** Degrees above the horizon (from the unclamped picture). */
|
|
46
|
+
elevation: number
|
|
47
|
+
/** A clear day's direct sun at that elevation, lux: 127 500 x exp(-0.21 / sin(elevation)). */
|
|
48
|
+
sunLux: number
|
|
49
|
+
/** environmentIntensity that makes the picture's own disc that bright: sunLux / discUnits - pixel 1.0 in nits. */
|
|
50
|
+
environmentIntensity: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SkyResult = {
|
|
54
|
+
sun?: SunCalibration
|
|
55
|
+
skyPath: string | null
|
|
56
|
+
iblPath: string | null
|
|
57
|
+
analysis: SkyAnalysis | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const runCmgen = (cmgen: string, args: string[]): string => {
|
|
61
|
+
const p = spawnSync(cmgen, args, { encoding: "utf8" })
|
|
62
|
+
if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
|
|
63
|
+
if (p.status !== 0) {
|
|
64
|
+
const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
|
|
65
|
+
throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
|
|
66
|
+
}
|
|
67
|
+
return `${p.stdout ?? ""}${p.stderr ?? ""}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
|
|
71
|
+
const deployed = (dir: string, suffix: string): string | null => {
|
|
72
|
+
if (!existsSync(dir)) return null
|
|
73
|
+
const hit = readdirSync(dir).find((f) => f.endsWith(`${suffix}.ktx`))
|
|
74
|
+
return hit ? join(dir, hit) : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<SkyResult> => {
|
|
78
|
+
const log = opts.log ?? (() => {})
|
|
79
|
+
if (!existsSync(input)) throw new Error(`not found: ${input}`)
|
|
80
|
+
|
|
81
|
+
const ext = extname(input).toLowerCase()
|
|
82
|
+
if (![".exr", ".hdr", ".png", ".psd"].includes(ext)) {
|
|
83
|
+
throw new Error(`unsupported input "${ext}" — cmgen reads .exr, .hdr, .png or .psd (equirectangular).`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cmgen = findCmgen()
|
|
87
|
+
if (!checkCmgen(cmgen)) throw new Error(CMGEN_HELP)
|
|
88
|
+
|
|
89
|
+
const outDir = resolve(opts.outDir ?? resolve(input, ".."))
|
|
90
|
+
mkdirSync(outDir, { recursive: true })
|
|
91
|
+
const size = opts.size ?? 512
|
|
92
|
+
const iblSize = opts.iblSize ?? 256
|
|
93
|
+
const skyName = opts.skyName ?? "sky"
|
|
94
|
+
const iblName = opts.iblName ?? "ibl"
|
|
95
|
+
|
|
96
|
+
const stage = mkdtempSync(join(tmpdir(), "lecodes-sky-"))
|
|
97
|
+
const result: SkyResult = { skyPath: null, iblPath: null, analysis: null }
|
|
98
|
+
try {
|
|
99
|
+
// Two passes: the sky and the probe want different resolutions, and --deploy sizes both at once.
|
|
100
|
+
if (!opts.iblOnly) {
|
|
101
|
+
log(` cubemap ${size}… (sky)`)
|
|
102
|
+
const d = join(stage, "sky")
|
|
103
|
+
runCmgen(cmgen, ["-q", "--format=ktx", `--size=${size}`, `--deploy=${d}`, input])
|
|
104
|
+
const src = deployed(d, "_skybox")
|
|
105
|
+
if (!src) throw new Error("cmgen produced no _skybox.ktx")
|
|
106
|
+
result.skyPath = join(outDir, `${skyName}.ktx`)
|
|
107
|
+
copyFileSync(src, result.skyPath)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log(` cubemap ${iblSize}… (lighting probe + SH)`)
|
|
111
|
+
const d2 = join(stage, "ibl")
|
|
112
|
+
let discUnits = 0
|
|
113
|
+
const sunArgs = opts.noSun ? [typeof opts.noSun === "number" ? `--clamp-sun=${opts.noSun}` : "--clamp-sun"] : []
|
|
114
|
+
const iblOut = runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, ...sunArgs, `--deploy=${d2}`, input])
|
|
115
|
+
if (opts.noSun) {
|
|
116
|
+
const line = /Sun clamped:[^\r\n]*/.exec(iblOut)
|
|
117
|
+
if (!line) throw new Error("this cmgen has no --clamp-sun (it is the LeCodes filament fork's): point LECODES_CMGEN at hosts/desktop/build/creator-gl/filament/tools/cmgen")
|
|
118
|
+
log(` ${line[0]}`)
|
|
119
|
+
const disc = /disc's illuminance ([0-9.eE+-]+)/.exec(iblOut)
|
|
120
|
+
if (disc) discUnits = parseFloat(disc[1])
|
|
121
|
+
}
|
|
122
|
+
const iblSrc = deployed(d2, "_ibl")
|
|
123
|
+
if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
|
|
124
|
+
result.iblPath = join(outDir, `${iblName}.ktx`)
|
|
125
|
+
copyFileSync(iblSrc, result.iblPath)
|
|
126
|
+
|
|
127
|
+
// Analyse whichever cubemap is sharpest — the sky when we made one, else the probe. A probe WITHOUT its sun says
|
|
128
|
+
// nothing about where the sun is: with `noSun` and no drawn sky, one more small pass keeps the disc for the reading.
|
|
129
|
+
let toScan = result.skyPath ?? result.iblPath
|
|
130
|
+
if (opts.noSun && !result.skyPath) {
|
|
131
|
+
const d3 = join(stage, "scan")
|
|
132
|
+
runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d3}`, input])
|
|
133
|
+
toScan = deployed(d3, "_skybox") ?? deployed(d3, "_ibl") ?? toScan
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
|
|
137
|
+
} catch (e) {
|
|
138
|
+
log(` (could not analyse the environment: ${(e as Error).message})`)
|
|
139
|
+
}
|
|
140
|
+
// THE CALIBRATION: the picture's units are relative, the sun's illuminance is not. A clear day's direct sun at
|
|
141
|
+
// elevation h is 127 500 x exp(-0.21 / sin h) lux (the extraterrestrial constant through a clear atmosphere's
|
|
142
|
+
// optical depth); environmentIntensity = that / the disc's illuminance in the picture's units - pixel 1.0 in nits.
|
|
143
|
+
const elevation = result.analysis?.elevation ?? 0
|
|
144
|
+
if (discUnits > 0 && elevation > 2 && (result.analysis?.peakLuminance ?? 0) >= 100) {
|
|
145
|
+
const sunLux = 127500 * Math.exp(-0.21 / Math.sin(elevation * Math.PI / 180))
|
|
146
|
+
result.sun = { discUnits, elevation, sunLux, environmentIntensity: sunLux / discUnits }
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(stage, { recursive: true, force: true })
|
|
150
|
+
}
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The report + the scene snippet to paste. The analysis is the point of the command: it turns
|
|
155
|
+
* "aim the sun by eye until the shadows look right" into a number read off the environment. */
|
|
156
|
+
export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
|
|
157
|
+
const out: string[] = []
|
|
158
|
+
const mb = (p: string): string => `${(statSync(p).size / 1048576).toFixed(1)} MB`
|
|
159
|
+
if (r.skyPath) out.push(`💾 ${r.skyPath} (${mb(r.skyPath)}) — drawn as the sky`)
|
|
160
|
+
if (r.iblPath) out.push(`💾 ${r.iblPath} (${mb(r.iblPath)}) — lights the scene`)
|
|
161
|
+
const a = r.analysis
|
|
162
|
+
if (!a) return out.join("\n")
|
|
163
|
+
|
|
164
|
+
const [dx, dy, dz] = a.lightDirection
|
|
165
|
+
out.push("")
|
|
166
|
+
out.push(`🔆 sun at ${a.elevation}° above the horizon · tint ${a.sunTint} · peak ${a.peakLuminance}`)
|
|
167
|
+
if (a.peakLuminance < 100) {
|
|
168
|
+
out.push(
|
|
169
|
+
` NOTE: that peak is low — this environment has no sun DISC (overcast, or the sun is below\n` +
|
|
170
|
+
` the horizon). The direction below is just the brightest part of the sky glow, and a strong\n` +
|
|
171
|
+
` white directional light under it will read as wrong as one pointing the wrong way.`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
// Sunlight is never blue. A blue-dominant core means the disc is clipped in this HDRI and the
|
|
175
|
+
// reading has picked up the intensely blue sky glow around it — common in the 1k/2k versions.
|
|
176
|
+
// The DIRECTION is still trustworthy; the colour is not, so say so instead of handing over a
|
|
177
|
+
// number that would light the level with blue "sunlight".
|
|
178
|
+
const blueSun = parseInt(a.sunTint.slice(5, 7), 16) > parseInt(a.sunTint.slice(1, 3), 16) + 8
|
|
179
|
+
if (blueSun && a.peakLuminance >= 100) {
|
|
180
|
+
out.push(
|
|
181
|
+
` NOTE: that tint is BLUE-dominant, which no sun is — the disc is clipped in this file and the\n` +
|
|
182
|
+
` reading caught the sky glow around it. Trust the direction, not the colour: use a neutral\n` +
|
|
183
|
+
` warm white (#fff2e2 or so) and tint to taste.`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const round2 = (v: number): number => { const m = Math.pow(10, Math.max(0, Math.floor(Math.log10(v)) - 1)); return Math.round(v / m) * m }
|
|
187
|
+
const c = r.sun
|
|
188
|
+
if (c) {
|
|
189
|
+
out.push(` CALIBRATED BY THE SUN: its disc is ${c.discUnits.toFixed(2)} of the picture's units of illuminance; a clear day's sun`)
|
|
190
|
+
out.push(` at ${c.elevation}° is ~${round2(c.sunLux)} lux, so pixel 1.0 = ${round2(c.environmentIntensity)} nits. The probe has the disc taken out:`)
|
|
191
|
+
out.push(` the directional light below IS the sun - give it that illuminance, do not tune the two by eye.`)
|
|
192
|
+
} else {
|
|
193
|
+
out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
|
|
194
|
+
out.push(` the lower environmentIntensity wants to be.${a.peakLuminance >= 100 ? " --no-sun measures the disc and gives the number." : ""}`)
|
|
195
|
+
}
|
|
196
|
+
out.push("")
|
|
197
|
+
out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: ${c ? round2(c.environmentIntensity) : 16000} }`)
|
|
198
|
+
out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}'${c ? `, intensity: ${round2(c.sunLux)}` : ""} }`)
|
|
199
|
+
return out.join("\n")
|
|
200
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// `lecodes assets terrain-pack` — a terrain's splat layers (up to four) → what `Terrain.create`'s
|
|
2
|
+
// `layers`/`normals` read (docs/terrain-plan.md §1.5):
|
|
3
|
+
//
|
|
4
|
+
// <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped — with the layer's ROUGHNESS in its ALPHA when
|
|
5
|
+
// the layer has a roughness source (terrain.mat: roughness = the layer's factor x albedo alpha; the
|
|
6
|
+
// one channel the sampler budget leaves free). No source = no alpha = the factor alone.
|
|
7
|
+
// normals01.ktx2 layer 0's normal XY in RG + layer 1's in BA (linear)
|
|
8
|
+
// normals23.ktx2 layers 2 + 3 likewise (only when there are more than two layers)
|
|
9
|
+
//
|
|
10
|
+
// The packing exists because terrain.mat runs at Filament feature level 1 (nine samplers): four
|
|
11
|
+
// albedos + four separate normal maps + the control map + the lightmap would be ten. Z is
|
|
12
|
+
// reconstructed in the shader, so only XY ship. A layer without a normal map gets a flat (128,128).
|
|
13
|
+
//
|
|
14
|
+
// Inputs: an albedo file per layer (PNG/JPEG/WebP/TIFF via sharp, TGA/BMP decoded here) with the
|
|
15
|
+
// normal map given by `--normals`, guessed from the albedo's name (`<x>_Albedo.png` → `<x>_Normal.png`),
|
|
16
|
+
// or — the Unity-pack shape — a FOLDER per layer holding `*_Albedo.*` + `*_Normal.*`.
|
|
17
|
+
// Roughness: `--roughness` per layer, guessed from the name (`<x>_Roughness.*`), or a folder's `*Roughness*` /
|
|
18
|
+
// `*AlbedoSmoothness*` (Unity: smoothness in the ALPHA, inverted here). A roughness image is read from its GREEN channel:
|
|
19
|
+
// a grey map has it in every channel, a glTF metallicRoughness / ORM map has roughness there.
|
|
20
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
|
|
21
|
+
import { basename, extname, join, resolve } from "node:path"
|
|
22
|
+
import { spawnSync } from "node:child_process"
|
|
23
|
+
import { decodeBmp, decodeTga, encodePng, sniffMime } from "./textures/resolveImage"
|
|
24
|
+
import { findKtxEncoder, ktxEncodeArgs, KTX_ENCODER_MISSING, type KtxEncoder } from "./doctor/transcodeKtx2"
|
|
25
|
+
|
|
26
|
+
export type TerrainPackLayer = {
|
|
27
|
+
name: string, albedo: string, normal?: string
|
|
28
|
+
/** a roughness image (read from its green channel) … */
|
|
29
|
+
roughness?: string
|
|
30
|
+
/** … or a Unity `*_AlbedoSmoothness` image: roughness = 1 − its alpha */
|
|
31
|
+
smoothnessAlpha?: string
|
|
32
|
+
/** A per-channel scale on the albedo, applied in LINEAR light (Unity's TerrainLayer `m_DiffuseRemapMax`: a tint
|
|
33
|
+
* the artist dialled on the layer, which the packed texture must carry since a layer has no tint of its own). */
|
|
34
|
+
albedoScale?: [number, number, number]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TerrainPackOptions = {
|
|
38
|
+
/** Where the files land. */
|
|
39
|
+
outDir: string
|
|
40
|
+
/** Output edge in pixels — every texture is resampled square (default 1024). */
|
|
41
|
+
size?: number
|
|
42
|
+
/** Write PNGs instead of KTX2 (no KTX-Software needed; larger, uncompressed on the GPU). */
|
|
43
|
+
png?: boolean
|
|
44
|
+
/** Invert the normal maps' green channel (DirectX-style sources; Unity's are OpenGL-style, the default). */
|
|
45
|
+
flipY?: boolean
|
|
46
|
+
/** UASTC quality 0–4 (default 1). */
|
|
47
|
+
quality?: number
|
|
48
|
+
log?: (msg: string) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TerrainPackResult = {
|
|
52
|
+
layers: { name: string, file: string, albedo: string, normal: string | null, roughness: string | null, sourceSize: [number, number] }[]
|
|
53
|
+
normals: string[]
|
|
54
|
+
/** The matching `Terrain.create` fragment. */
|
|
55
|
+
snippet: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|tiff?|tga|bmp)$/i
|
|
59
|
+
|
|
60
|
+
/** A layer from a file (+ optional normal) or a folder (`*Albedo*` + `*Normal*` inside). */
|
|
61
|
+
export const resolveTerrainLayer = (input: string, normal?: string, name?: string, roughness?: string): TerrainPackLayer => {
|
|
62
|
+
const p = resolve(input)
|
|
63
|
+
if (!existsSync(p)) throw new Error(`not found: ${input}`)
|
|
64
|
+
if (statSync(p).isDirectory()) {
|
|
65
|
+
const files = readdirSync(p).filter((f) => IMAGE_EXT.test(f))
|
|
66
|
+
// plain "_Albedo" over "_AlbedoSmoothness" (Unity packs ship both; the smoothness alpha is dropped anyway)
|
|
67
|
+
const albedo = files.find((f) => /albedo/i.test(f) && !/smooth/i.test(f)) ?? files.find((f) => /albedo|basecolor|base_color|diffuse|_d\./i.test(f))
|
|
68
|
+
if (!albedo) throw new Error(`${input}: no albedo image (a file named *Albedo* / *BaseColor* / *Diffuse*)`)
|
|
69
|
+
const nrm = normal ?? files.find((f) => /normal|_n\./i.test(f) && !/normalmap_?dx/i.test(f))
|
|
70
|
+
const rough = roughness ? resolve(roughness) : files.find((f) => /rough/i.test(f))
|
|
71
|
+
const smooth = rough ? undefined : files.find((f) => /albedo.*smooth|smooth.*albedo/i.test(f))
|
|
72
|
+
return {
|
|
73
|
+
name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined,
|
|
74
|
+
roughness: rough ? (roughness ? rough : join(p, rough)) : undefined, smoothnessAlpha: smooth ? join(p, smooth) : undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!IMAGE_EXT.test(p)) throw new Error(`${input}: not an image (png/jpg/webp/tiff/tga/bmp)`)
|
|
78
|
+
let nrm: string | undefined = normal ? resolve(normal) : undefined
|
|
79
|
+
if (!nrm) {
|
|
80
|
+
const stem = p.slice(0, -extname(p).length)
|
|
81
|
+
for (const cand of [ stem.replace(/albedo/i, "Normal"), stem.replace(/albedo/i, "normal"), `${stem}_Normal`, `${stem}_normal`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Normal") ]) {
|
|
82
|
+
if (cand === stem) continue
|
|
83
|
+
const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
|
|
84
|
+
if (hit) { nrm = hit; break }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let rough: string | undefined = roughness ? resolve(roughness) : undefined
|
|
88
|
+
if (!rough) {
|
|
89
|
+
const stem = p.slice(0, -extname(p).length)
|
|
90
|
+
for (const cand of [ stem.replace(/albedo/i, "Roughness"), stem.replace(/albedo/i, "roughness"), `${stem}_Roughness`, `${stem}_roughness`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Roughness") ]) {
|
|
91
|
+
if (cand === stem) continue
|
|
92
|
+
const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
|
|
93
|
+
if (hit) { rough = hit; break }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm, roughness: rough }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cleanName = (s: string): string => s.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_-]/g, "").toLowerCase() || "layer"
|
|
100
|
+
|
|
101
|
+
type Raw = { data: Buffer, width: number, height: number }
|
|
102
|
+
|
|
103
|
+
/** Any supported image → RGBA at `size`² (TGA/BMP through the in-house decoders, the rest via sharp). */
|
|
104
|
+
const loadRgba = async (path: string, size: number): Promise<Raw & { sourceSize: [number, number] }> => {
|
|
105
|
+
const sharp = (await import("sharp")).default
|
|
106
|
+
let bytes: Uint8Array = new Uint8Array(readFileSync(path))
|
|
107
|
+
const mime = sniffMime(bytes, path)
|
|
108
|
+
if (mime === "image/x-tga" || mime === "image/bmp") {
|
|
109
|
+
const rgba = mime === "image/x-tga" ? decodeTga(bytes) : decodeBmp(bytes)
|
|
110
|
+
if (!rgba) throw new Error(`${path}: could not decode the ${mime === "image/bmp" ? "BMP" : "TGA"}`)
|
|
111
|
+
bytes = encodePng(rgba)
|
|
112
|
+
}
|
|
113
|
+
const img = sharp(Buffer.from(bytes))
|
|
114
|
+
const meta = await img.metadata()
|
|
115
|
+
const { data, info } = await img.resize(size, size, { fit: "fill", kernel: "lanczos3" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
116
|
+
return { data, width: info.width, height: info.height, sourceSize: [ meta.width ?? 0, meta.height ?? 0 ] }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const encodeKtx2 = (enc: KtxEncoder, png: string, ktx2: string, channels: 3 | 4, srgb: boolean, quality: number): void => {
|
|
120
|
+
const args = ktxEncodeArgs(enc, { input: png, output: ktx2, codec: "uastc", oetf: srgb ? "srgb" : "linear", channels, uastcQuality: quality, zstd: 18 })
|
|
121
|
+
const p = spawnSync(enc.path, args, { encoding: "utf8" })
|
|
122
|
+
if (p.status !== 0) throw new Error(`${enc.kind === "ktx" ? "ktx create" : "toktx"} failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: TerrainPackOptions): Promise<TerrainPackResult> => {
|
|
126
|
+
if (layers.length === 0) throw new Error("no layers")
|
|
127
|
+
if (layers.length > 4) throw new Error(`${layers.length} layers — a terrain material takes at most four`)
|
|
128
|
+
const size = opts.size ?? 1024
|
|
129
|
+
if (!(size >= 16 && size <= 8192) || (size & (size - 1)) !== 0) throw new Error(`--size wants a power of two in 16..8192, got ${size}`)
|
|
130
|
+
const log = opts.log ?? (() => {})
|
|
131
|
+
const sharp = (await import("sharp")).default
|
|
132
|
+
const enc = opts.png ? null : findKtxEncoder()
|
|
133
|
+
if (!opts.png && !enc) throw new Error(`${KTX_ENCODER_MISSING}\n Or pass --png.`)
|
|
134
|
+
const seen = new Set<string>()
|
|
135
|
+
for (const l of layers) {
|
|
136
|
+
if (seen.has(l.name)) throw new Error(`two layers named "${l.name}" — pass --names`)
|
|
137
|
+
seen.add(l.name)
|
|
138
|
+
}
|
|
139
|
+
mkdirSync(opts.outDir, { recursive: true })
|
|
140
|
+
const ext = opts.png ? ".png" : ".ktx2"
|
|
141
|
+
|
|
142
|
+
const writeImage = async (raw: Raw, channels: 3 | 4, out: string, srgb: boolean): Promise<void> => {
|
|
143
|
+
let pipe = sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: 4 } })
|
|
144
|
+
if (channels === 3) pipe = pipe.removeAlpha()
|
|
145
|
+
const png = opts.png ? out : out.slice(0, -ext.length) + ".png"
|
|
146
|
+
await pipe.png({ compressionLevel: opts.png ? 9 : 1 }).toFile(png)
|
|
147
|
+
if (enc) {
|
|
148
|
+
encodeKtx2(enc, png, out, channels, srgb, opts.quality ?? 1)
|
|
149
|
+
unlinkSync(png)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result: TerrainPackResult = { layers: [], normals: [], snippet: "" }
|
|
154
|
+
const normalRaws: (Raw | null)[] = []
|
|
155
|
+
for (const l of layers) {
|
|
156
|
+
const alb = await loadRgba(l.albedo, size)
|
|
157
|
+
if (l.albedoScale && l.albedoScale.some((s) => Math.abs(s - 1) > 1e-3)) {
|
|
158
|
+
const toLin = (v: number): number => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
|
|
159
|
+
const toSrgb = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055)
|
|
160
|
+
const lut = [ 0, 1, 2 ].map((c) => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(Math.min(1, toSrgb(toLin(i / 255) * l.albedoScale![c])) * 255); return t })
|
|
161
|
+
for (let i = 0; i < size * size; i++) for (let c = 0; c < 3; c++) alb.data[i * 4 + c] = lut[c][alb.data[i * 4 + c]]
|
|
162
|
+
}
|
|
163
|
+
const file = join(opts.outDir, l.name + ext)
|
|
164
|
+
// the layer's roughness rides the albedo's alpha (the header); without a source the albedo ships opaque
|
|
165
|
+
const roughSrc = l.roughness ?? l.smoothnessAlpha
|
|
166
|
+
if (roughSrc) {
|
|
167
|
+
if (!existsSync(roughSrc)) throw new Error(`${l.name}: roughness map not found: ${roughSrc}`)
|
|
168
|
+
const r = await loadRgba(roughSrc, size)
|
|
169
|
+
for (let i = 0; i < size * size; i++) alb.data[i * 4 + 3] = l.roughness ? r.data[i * 4 + 1] : 255 - r.data[i * 4 + 3]
|
|
170
|
+
}
|
|
171
|
+
await writeImage(alb, roughSrc ? 4 : 3, file, true)
|
|
172
|
+
let nrm: (Raw & { sourceSize: [number, number] }) | null = null
|
|
173
|
+
if (l.normal) {
|
|
174
|
+
if (!existsSync(l.normal)) throw new Error(`${l.name}: normal map not found: ${l.normal}`)
|
|
175
|
+
nrm = await loadRgba(l.normal, size)
|
|
176
|
+
}
|
|
177
|
+
normalRaws.push(nrm)
|
|
178
|
+
result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, roughness: roughSrc ?? null, sourceSize: alb.sourceSize })
|
|
179
|
+
log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}${roughSrc ? ` + roughness in the alpha (${l.roughness ? basename(roughSrc) : "1 − " + basename(roughSrc) + "'s alpha"})` : ""}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// the packs: RG = layer 2k's XY, BA = layer 2k+1's; a missing map is flat
|
|
183
|
+
const packCount = layers.length > 2 ? 2 : 1
|
|
184
|
+
for (let k = 0; k < packCount; k++) {
|
|
185
|
+
const a = normalRaws[k * 2] ?? null, b = normalRaws[k * 2 + 1] ?? null
|
|
186
|
+
const n = size * size
|
|
187
|
+
const out = Buffer.alloc(n * 4)
|
|
188
|
+
for (let i = 0; i < n; i++) {
|
|
189
|
+
const o = i * 4
|
|
190
|
+
out[o] = a ? a.data[o] : 128
|
|
191
|
+
out[o + 1] = a ? (opts.flipY ? 255 - a.data[o + 1] : a.data[o + 1]) : 128
|
|
192
|
+
out[o + 2] = b ? b.data[o] : 128
|
|
193
|
+
out[o + 3] = b ? (opts.flipY ? 255 - b.data[o + 1] : b.data[o + 1]) : 128
|
|
194
|
+
}
|
|
195
|
+
const file = join(opts.outDir, `normals${k * 2}${k * 2 + 1}${ext}`)
|
|
196
|
+
await writeImage({ data: out, width: size, height: size }, 4, file, false)
|
|
197
|
+
result.normals.push(file)
|
|
198
|
+
log(` normals${k * 2}${k * 2 + 1}${"".padEnd(7)} ${a ? layers[k * 2].name : "flat"} + ${b ? layers[k * 2 + 1].name : layers[k * 2 + 1] ? "flat" : "—"}`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const rel = (f: string): string => "./" + basename(f)
|
|
202
|
+
result.snippet = [
|
|
203
|
+
"layers: [",
|
|
204
|
+
...result.layers.map((l, i) => ` { albedo: asset('${rel(l.file)}'), tiling: 4${normalRaws[i] ? ", normalScale: 1" : ""} },`),
|
|
205
|
+
`], normals: [ ${result.normals.map((f) => `asset('${rel(f)}')`).join(", ")} ]`,
|
|
206
|
+
].join("\n")
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Turn an FBX texture reference (embedded bytes or a filename) into GLB-embeddable image bytes.
|
|
2
|
+
// PNG/JPEG pass through; TGA/BMP are decoded here and re-encoded as PNG (no native deps); anything
|
|
3
|
+
// else goes through `sharp` when it's installed, otherwise the texture is skipped with a warning.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { basename, dirname, join, isAbsolute } from "node:path"
|
|
7
|
+
import { deflateSync } from "node:zlib"
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import type { FbxTexture } from "../fbx/read"
|
|
10
|
+
|
|
11
|
+
export type ResolvedImage = { data: Uint8Array, mimeType: "image/png" | "image/jpeg", hash: string, converted: boolean, source: string }
|
|
12
|
+
type Warn = (w: { level: "warn" | "info", message: string }) => void
|
|
13
|
+
|
|
14
|
+
export const sniffMime = (b: Uint8Array, hintName = ""): string => {
|
|
15
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"
|
|
16
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"
|
|
17
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"
|
|
18
|
+
if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return "image/bmp"
|
|
19
|
+
if (b.length >= 4 && b[0] === 0x44 && b[1] === 0x44 && b[2] === 0x53 && b[3] === 0x20) return "image/vnd-ms.dds"
|
|
20
|
+
if (b.length >= 4 && b[0] === 0x38 && b[1] === 0x42 && b[2] === 0x50 && b[3] === 0x53) return "image/vnd.adobe.photoshop"
|
|
21
|
+
if (b.length >= 4 && ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) || (b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00))) return "image/tiff"
|
|
22
|
+
if (b.length >= 12 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58) return "image/ktx2"
|
|
23
|
+
const ext = hintName.toLowerCase().split(".").pop() ?? ""
|
|
24
|
+
if (ext === "tga" || ext === "targa") return "image/x-tga"
|
|
25
|
+
// TGA has no magic; accept the footer or a plausible header when the extension says so
|
|
26
|
+
if (b.length > 26) {
|
|
27
|
+
const foot = new TextDecoder().decode(b.subarray(b.length - 18, b.length - 2))
|
|
28
|
+
if (foot === "TRUEVISION-XFILE") return "image/x-tga"
|
|
29
|
+
}
|
|
30
|
+
return "application/octet-stream"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hashOf = (b: Uint8Array): string => createHash("sha1").update(b).digest("hex")
|
|
34
|
+
|
|
35
|
+
/** Resolve an FBX texture to PNG/JPEG bytes (null = unusable, warned). */
|
|
36
|
+
export const resolveTextureImage = async (t: FbxTexture, embedded: Uint8Array | null, baseDir: string | undefined, warn: Warn): Promise<ResolvedImage | null> => {
|
|
37
|
+
let data: Uint8Array | null = embedded && embedded.length > 0 ? embedded : null
|
|
38
|
+
let source = "embedded"
|
|
39
|
+
const hint = t.relative || t.filename || t.absolute || t.name
|
|
40
|
+
if (!data) {
|
|
41
|
+
const found = findExternal(t, baseDir)
|
|
42
|
+
if (!found) { warn({ level: "warn", message: `texture '${hint}': not embedded and not found next to the FBX — skipped` }); return null }
|
|
43
|
+
data = new Uint8Array(readFileSync(found))
|
|
44
|
+
source = found
|
|
45
|
+
}
|
|
46
|
+
const mime = sniffMime(data, hint)
|
|
47
|
+
if (mime === "image/png" || mime === "image/jpeg") return { data, mimeType: mime, hash: hashOf(data), converted: false, source }
|
|
48
|
+
if (mime === "image/x-tga") {
|
|
49
|
+
const rgba = decodeTga(data)
|
|
50
|
+
if (rgba) { const png = encodePng(rgba); return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source } }
|
|
51
|
+
}
|
|
52
|
+
if (mime === "image/bmp") {
|
|
53
|
+
const rgba = decodeBmp(data)
|
|
54
|
+
if (rgba) { const png = encodePng(rgba); return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source } }
|
|
55
|
+
}
|
|
56
|
+
// last resort: sharp (optional dependency)
|
|
57
|
+
try {
|
|
58
|
+
const sharp = (await import("sharp")).default
|
|
59
|
+
const png = new Uint8Array(await sharp(Buffer.from(data)).png().toBuffer())
|
|
60
|
+
return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
warn({ level: "warn", message: `texture '${hint}': ${mime} is not supported without 'sharp' (bun add sharp) — skipped` })
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const findExternal = (t: FbxTexture, baseDir: string | undefined): string | null => {
|
|
68
|
+
const candidates: string[] = []
|
|
69
|
+
const names = [ t.absolute, t.filename, t.relative ].filter(Boolean)
|
|
70
|
+
for (const n of names) {
|
|
71
|
+
if (isAbsolute(n) && existsSync(n)) return n
|
|
72
|
+
if (baseDir) {
|
|
73
|
+
candidates.push(join(baseDir, n))
|
|
74
|
+
const b = basename(n.replace(/\\/g, "/"))
|
|
75
|
+
candidates.push(join(baseDir, b), join(baseDir, "textures", b), join(baseDir, "Textures", b))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const c of candidates) if (existsSync(c)) return c
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- TGA --------------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export type Rgba = { width: number, height: number, data: Uint8Array }
|
|
85
|
+
|
|
86
|
+
export const decodeTga = (b: Uint8Array): Rgba | null => {
|
|
87
|
+
if (b.length < 18) return null
|
|
88
|
+
const idLen = b[0], cmapType = b[1], imgType = b[2]
|
|
89
|
+
const cmapStart = b[3] | (b[4] << 8), cmapLen = b[5] | (b[6] << 8), cmapBits = b[7]
|
|
90
|
+
const width = b[12] | (b[13] << 8), height = b[14] | (b[15] << 8)
|
|
91
|
+
const bpp = b[16], desc = b[17]
|
|
92
|
+
const topDown = (desc & 0x20) !== 0
|
|
93
|
+
const rle = imgType === 9 || imgType === 10 || imgType === 11
|
|
94
|
+
const kind = imgType & 7 // 1 = colormapped, 2 = truecolor, 3 = grayscale
|
|
95
|
+
if (width === 0 || height === 0 || ![ 1, 2, 3 ].includes(kind)) return null
|
|
96
|
+
let p = 18 + idLen
|
|
97
|
+
let cmap: Uint8Array | null = null
|
|
98
|
+
const cmapBytes = (cmapBits + 7) >> 3
|
|
99
|
+
if (cmapType === 1) { cmap = b.subarray(p, p + cmapLen * cmapBytes); p += cmapLen * cmapBytes }
|
|
100
|
+
const bytesPP = bpp >> 3
|
|
101
|
+
const out = new Uint8Array(width * height * 4)
|
|
102
|
+
const px = new Uint8Array(4)
|
|
103
|
+
const readPixel = (at: number): void => {
|
|
104
|
+
if (kind === 3) { const g = b[at]; px[0] = g; px[1] = g; px[2] = g; px[3] = bytesPP > 1 ? b[at + 1] : 255; return }
|
|
105
|
+
if (kind === 1 && cmap) {
|
|
106
|
+
const idx = (bytesPP === 2 ? b[at] | (b[at + 1] << 8) : b[at]) - cmapStart
|
|
107
|
+
const c = idx * cmapBytes
|
|
108
|
+
if (cmapBytes >= 3) { px[0] = cmap[c + 2]; px[1] = cmap[c + 1]; px[2] = cmap[c]; px[3] = cmapBytes === 4 ? cmap[c + 3] : 255 }
|
|
109
|
+
else { const v = cmap[c] | (cmap[c + 1] << 8); px[0] = ((v >> 10) & 31) * 8; px[1] = ((v >> 5) & 31) * 8; px[2] = (v & 31) * 8; px[3] = 255 }
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (bytesPP === 4) { px[0] = b[at + 2]; px[1] = b[at + 1]; px[2] = b[at]; px[3] = b[at + 3]; return }
|
|
113
|
+
if (bytesPP === 3) { px[0] = b[at + 2]; px[1] = b[at + 1]; px[2] = b[at]; px[3] = 255; return }
|
|
114
|
+
if (bytesPP === 2) { const v = b[at] | (b[at + 1] << 8); px[0] = ((v >> 10) & 31) * 8; px[1] = ((v >> 5) & 31) * 8; px[2] = (v & 31) * 8; px[3] = 255; return }
|
|
115
|
+
px[0] = px[1] = px[2] = b[at]; px[3] = 255
|
|
116
|
+
}
|
|
117
|
+
const total = width * height
|
|
118
|
+
let i = 0
|
|
119
|
+
const put = (): void => {
|
|
120
|
+
const y = topDown ? Math.floor(i / width) : height - 1 - Math.floor(i / width)
|
|
121
|
+
const o = (y * width + (i % width)) * 4
|
|
122
|
+
out[o] = px[0]; out[o + 1] = px[1]; out[o + 2] = px[2]; out[o + 3] = px[3]
|
|
123
|
+
i++
|
|
124
|
+
}
|
|
125
|
+
if (!rle) {
|
|
126
|
+
for (; i < total && p + bytesPP <= b.length;) { readPixel(p); p += bytesPP; put() }
|
|
127
|
+
} else {
|
|
128
|
+
while (i < total && p < b.length) {
|
|
129
|
+
const head = b[p++]
|
|
130
|
+
const count = (head & 0x7f) + 1
|
|
131
|
+
if (head & 0x80) { readPixel(p); p += bytesPP; for (let k = 0; k < count && i < total; k++) put() }
|
|
132
|
+
else for (let k = 0; k < count && i < total && p + bytesPP <= b.length; k++) { readPixel(p); p += bytesPP; put() }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { width, height, data: out }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- BMP (uncompressed 24/32-bit) --------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export const decodeBmp = (b: Uint8Array): Rgba | null => {
|
|
141
|
+
if (b.length < 54 || b[0] !== 0x42 || b[1] !== 0x4d) return null
|
|
142
|
+
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
|
|
143
|
+
const offset = dv.getUint32(10, true)
|
|
144
|
+
const width = dv.getInt32(18, true)
|
|
145
|
+
const rawHeight = dv.getInt32(22, true)
|
|
146
|
+
const bpp = dv.getUint16(28, true)
|
|
147
|
+
const compression = dv.getUint32(30, true)
|
|
148
|
+
if ((bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3)) return null
|
|
149
|
+
const height = Math.abs(rawHeight)
|
|
150
|
+
const topDown = rawHeight < 0
|
|
151
|
+
const stride = ((width * bpp / 8) + 3) & ~3
|
|
152
|
+
const out = new Uint8Array(width * height * 4)
|
|
153
|
+
for (let y = 0; y < height; y++) {
|
|
154
|
+
const srcY = topDown ? y : height - 1 - y
|
|
155
|
+
let s = offset + srcY * stride
|
|
156
|
+
for (let x = 0; x < width; x++) {
|
|
157
|
+
const o = (y * width + x) * 4
|
|
158
|
+
out[o] = b[s + 2]; out[o + 1] = b[s + 1]; out[o + 2] = b[s]; out[o + 3] = bpp === 32 ? b[s + 3] : 255
|
|
159
|
+
s += bpp / 8
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { width, height, data: out }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- PNG encoder (RGBA8, filter 0, zlib) -----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const CRC_TABLE = (() => {
|
|
168
|
+
const t = new Uint32Array(256)
|
|
169
|
+
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 }
|
|
170
|
+
return t
|
|
171
|
+
})()
|
|
172
|
+
const crc32 = (parts: Uint8Array[]): number => {
|
|
173
|
+
let c = 0xffffffff
|
|
174
|
+
for (const p of parts) for (let i = 0; i < p.length; i++) c = CRC_TABLE[(c ^ p[i]) & 0xff] ^ (c >>> 8)
|
|
175
|
+
return (c ^ 0xffffffff) >>> 0
|
|
176
|
+
}
|
|
177
|
+
const chunk = (type: string, data: Uint8Array): Uint8Array => {
|
|
178
|
+
const t = new TextEncoder().encode(type)
|
|
179
|
+
const out = new Uint8Array(12 + data.length)
|
|
180
|
+
const dv = new DataView(out.buffer)
|
|
181
|
+
dv.setUint32(0, data.length)
|
|
182
|
+
out.set(t, 4); out.set(data, 8)
|
|
183
|
+
dv.setUint32(8 + data.length, crc32([ t, data ]))
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const encodePng = (img: Rgba): Uint8Array => {
|
|
188
|
+
const { width, height, data } = img
|
|
189
|
+
const raw = new Uint8Array((width * 4 + 1) * height)
|
|
190
|
+
for (let y = 0; y < height; y++) { raw[y * (width * 4 + 1)] = 0; raw.set(data.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1) }
|
|
191
|
+
const ihdr = new Uint8Array(13)
|
|
192
|
+
const dv = new DataView(ihdr.buffer)
|
|
193
|
+
dv.setUint32(0, width); dv.setUint32(4, height)
|
|
194
|
+
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
|
|
195
|
+
const idat = new Uint8Array(deflateSync(raw, { level: 6 }))
|
|
196
|
+
const sig = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ])
|
|
197
|
+
const parts = [ sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0)) ]
|
|
198
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
199
|
+
let o = 0
|
|
200
|
+
for (const p of parts) { out.set(p, o); o += p.length }
|
|
201
|
+
return out
|
|
202
|
+
}
|