lecodes-assets 0.2.0 → 0.2.1
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 +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -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 +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -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 +171 -0
- package/src/retarget/humanoid.ts +561 -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 +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -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 +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -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/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,169 @@
|
|
|
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
|
|
5
|
+
// normals01.ktx2 layer 0's normal XY in RG + layer 1's in BA (linear)
|
|
6
|
+
// normals23.ktx2 layers 2 + 3 likewise (only when there are more than two layers)
|
|
7
|
+
//
|
|
8
|
+
// The packing exists because terrain.mat runs at Filament feature level 1 (nine samplers): four
|
|
9
|
+
// albedos + four separate normal maps + the control map + the lightmap would be ten. Z is
|
|
10
|
+
// reconstructed in the shader, so only XY ship. A layer without a normal map gets a flat (128,128).
|
|
11
|
+
//
|
|
12
|
+
// Inputs: an albedo file per layer (PNG/JPEG/WebP/TIFF via sharp, TGA/BMP decoded here) with the
|
|
13
|
+
// normal map given by `--normals`, guessed from the albedo's name (`<x>_Albedo.png` → `<x>_Normal.png`),
|
|
14
|
+
// or — the Unity-pack shape — a FOLDER per layer holding `*_Albedo.*` + `*_Normal.*`.
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
|
|
16
|
+
import { basename, extname, join, resolve } from "node:path"
|
|
17
|
+
import { spawnSync } from "node:child_process"
|
|
18
|
+
import { decodeBmp, decodeTga, encodePng, sniffMime } from "./textures/resolveImage"
|
|
19
|
+
import { findToktx, checkToktx } from "./doctor/transcodeKtx2"
|
|
20
|
+
|
|
21
|
+
export type TerrainPackLayer = { name: string, albedo: string, normal?: string }
|
|
22
|
+
|
|
23
|
+
export type TerrainPackOptions = {
|
|
24
|
+
/** Where the files land. */
|
|
25
|
+
outDir: string
|
|
26
|
+
/** Output edge in pixels — every texture is resampled square (default 1024). */
|
|
27
|
+
size?: number
|
|
28
|
+
/** Write PNGs instead of KTX2 (no toktx needed; larger, uncompressed on the GPU). */
|
|
29
|
+
png?: boolean
|
|
30
|
+
/** Invert the normal maps' green channel (DirectX-style sources; Unity's are OpenGL-style, the default). */
|
|
31
|
+
flipY?: boolean
|
|
32
|
+
/** toktx UASTC quality 0–4 (default 1). */
|
|
33
|
+
quality?: number
|
|
34
|
+
log?: (msg: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TerrainPackResult = {
|
|
38
|
+
layers: { name: string, file: string, albedo: string, normal: string | null, sourceSize: [number, number] }[]
|
|
39
|
+
normals: string[]
|
|
40
|
+
/** The matching `Terrain.create` fragment. */
|
|
41
|
+
snippet: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|tiff?|tga|bmp)$/i
|
|
45
|
+
|
|
46
|
+
/** A layer from a file (+ optional normal) or a folder (`*Albedo*` + `*Normal*` inside). */
|
|
47
|
+
export const resolveTerrainLayer = (input: string, normal?: string, name?: string): TerrainPackLayer => {
|
|
48
|
+
const p = resolve(input)
|
|
49
|
+
if (!existsSync(p)) throw new Error(`not found: ${input}`)
|
|
50
|
+
if (statSync(p).isDirectory()) {
|
|
51
|
+
const files = readdirSync(p).filter((f) => IMAGE_EXT.test(f))
|
|
52
|
+
// plain "_Albedo" over "_AlbedoSmoothness" (Unity packs ship both; the smoothness alpha is dropped anyway)
|
|
53
|
+
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))
|
|
54
|
+
if (!albedo) throw new Error(`${input}: no albedo image (a file named *Albedo* / *BaseColor* / *Diffuse*)`)
|
|
55
|
+
const nrm = normal ?? files.find((f) => /normal|_n\./i.test(f) && !/normalmap_?dx/i.test(f))
|
|
56
|
+
return { name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined }
|
|
57
|
+
}
|
|
58
|
+
if (!IMAGE_EXT.test(p)) throw new Error(`${input}: not an image (png/jpg/webp/tiff/tga/bmp)`)
|
|
59
|
+
let nrm: string | undefined = normal ? resolve(normal) : undefined
|
|
60
|
+
if (!nrm) {
|
|
61
|
+
const stem = p.slice(0, -extname(p).length)
|
|
62
|
+
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") ]) {
|
|
63
|
+
if (cand === stem) continue
|
|
64
|
+
const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
|
|
65
|
+
if (hit) { nrm = hit; break }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cleanName = (s: string): string => s.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_-]/g, "").toLowerCase() || "layer"
|
|
72
|
+
|
|
73
|
+
type Raw = { data: Buffer, width: number, height: number }
|
|
74
|
+
|
|
75
|
+
/** Any supported image → RGBA at `size`² (TGA/BMP through the in-house decoders, the rest via sharp). */
|
|
76
|
+
const loadRgba = async (path: string, size: number): Promise<Raw & { sourceSize: [number, number] }> => {
|
|
77
|
+
const sharp = (await import("sharp")).default
|
|
78
|
+
let bytes: Uint8Array = new Uint8Array(readFileSync(path))
|
|
79
|
+
const mime = sniffMime(bytes, path)
|
|
80
|
+
if (mime === "image/x-tga" || mime === "image/bmp") {
|
|
81
|
+
const rgba = mime === "image/x-tga" ? decodeTga(bytes) : decodeBmp(bytes)
|
|
82
|
+
if (!rgba) throw new Error(`${path}: could not decode the ${mime === "image/bmp" ? "BMP" : "TGA"}`)
|
|
83
|
+
bytes = encodePng(rgba)
|
|
84
|
+
}
|
|
85
|
+
const img = sharp(Buffer.from(bytes))
|
|
86
|
+
const meta = await img.metadata()
|
|
87
|
+
const { data, info } = await img.resize(size, size, { fit: "fill", kernel: "lanczos3" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
88
|
+
return { data, width: info.width, height: info.height, sourceSize: [ meta.width ?? 0, meta.height ?? 0 ] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const encodeKtx2 = (toktx: string, png: string, ktx2: string, srgb: boolean, quality: number): void => {
|
|
92
|
+
const args = [ "--t2", "--encode", "uastc", "--uastc_quality", String(quality), "--zcmp", "18", "--genmipmap", "--assign_oetf", srgb ? "srgb" : "linear", ktx2, png ]
|
|
93
|
+
const p = spawnSync(toktx, args, { encoding: "utf8" })
|
|
94
|
+
if (p.status !== 0) throw new Error(`toktx failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: TerrainPackOptions): Promise<TerrainPackResult> => {
|
|
98
|
+
if (layers.length === 0) throw new Error("no layers")
|
|
99
|
+
if (layers.length > 4) throw new Error(`${layers.length} layers — a terrain material takes at most four`)
|
|
100
|
+
const size = opts.size ?? 1024
|
|
101
|
+
if (!(size >= 16 && size <= 8192) || (size & (size - 1)) !== 0) throw new Error(`--size wants a power of two in 16..8192, got ${size}`)
|
|
102
|
+
const log = opts.log ?? (() => {})
|
|
103
|
+
const sharp = (await import("sharp")).default
|
|
104
|
+
const toktx = opts.png ? null : findToktx()
|
|
105
|
+
if (toktx && !checkToktx(toktx)) {
|
|
106
|
+
throw new Error("KTX-Software's toktx is needed for KTX2 output — install it (https://github.com/KhronosGroup/KTX-Software/releases, on PATH or KTX_TOKTX=<path>) or pass --png")
|
|
107
|
+
}
|
|
108
|
+
const seen = new Set<string>()
|
|
109
|
+
for (const l of layers) {
|
|
110
|
+
if (seen.has(l.name)) throw new Error(`two layers named "${l.name}" — pass --names`)
|
|
111
|
+
seen.add(l.name)
|
|
112
|
+
}
|
|
113
|
+
mkdirSync(opts.outDir, { recursive: true })
|
|
114
|
+
const ext = opts.png ? ".png" : ".ktx2"
|
|
115
|
+
|
|
116
|
+
const writeImage = async (raw: Raw, channels: 3 | 4, out: string, srgb: boolean): Promise<void> => {
|
|
117
|
+
let pipe = sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: 4 } })
|
|
118
|
+
if (channels === 3) pipe = pipe.removeAlpha()
|
|
119
|
+
const png = opts.png ? out : out.slice(0, -ext.length) + ".png"
|
|
120
|
+
await pipe.png({ compressionLevel: opts.png ? 9 : 1 }).toFile(png)
|
|
121
|
+
if (toktx) {
|
|
122
|
+
encodeKtx2(toktx, png, out, srgb, opts.quality ?? 1)
|
|
123
|
+
unlinkSync(png)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result: TerrainPackResult = { layers: [], normals: [], snippet: "" }
|
|
128
|
+
const normalRaws: (Raw | null)[] = []
|
|
129
|
+
for (const l of layers) {
|
|
130
|
+
const alb = await loadRgba(l.albedo, size)
|
|
131
|
+
const file = join(opts.outDir, l.name + ext)
|
|
132
|
+
await writeImage(alb, 3, file, true)
|
|
133
|
+
let nrm: (Raw & { sourceSize: [number, number] }) | null = null
|
|
134
|
+
if (l.normal) {
|
|
135
|
+
if (!existsSync(l.normal)) throw new Error(`${l.name}: normal map not found: ${l.normal}`)
|
|
136
|
+
nrm = await loadRgba(l.normal, size)
|
|
137
|
+
}
|
|
138
|
+
normalRaws.push(nrm)
|
|
139
|
+
result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, sourceSize: alb.sourceSize })
|
|
140
|
+
log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the packs: RG = layer 2k's XY, BA = layer 2k+1's; a missing map is flat
|
|
144
|
+
const packCount = layers.length > 2 ? 2 : 1
|
|
145
|
+
for (let k = 0; k < packCount; k++) {
|
|
146
|
+
const a = normalRaws[k * 2] ?? null, b = normalRaws[k * 2 + 1] ?? null
|
|
147
|
+
const n = size * size
|
|
148
|
+
const out = Buffer.alloc(n * 4)
|
|
149
|
+
for (let i = 0; i < n; i++) {
|
|
150
|
+
const o = i * 4
|
|
151
|
+
out[o] = a ? a.data[o] : 128
|
|
152
|
+
out[o + 1] = a ? (opts.flipY ? 255 - a.data[o + 1] : a.data[o + 1]) : 128
|
|
153
|
+
out[o + 2] = b ? b.data[o] : 128
|
|
154
|
+
out[o + 3] = b ? (opts.flipY ? 255 - b.data[o + 1] : b.data[o + 1]) : 128
|
|
155
|
+
}
|
|
156
|
+
const file = join(opts.outDir, `normals${k * 2}${k * 2 + 1}${ext}`)
|
|
157
|
+
await writeImage({ data: out, width: size, height: size }, 4, file, false)
|
|
158
|
+
result.normals.push(file)
|
|
159
|
+
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" : "—"}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rel = (f: string): string => "./" + basename(f)
|
|
163
|
+
result.snippet = [
|
|
164
|
+
"layers: [",
|
|
165
|
+
...result.layers.map((l, i) => ` { albedo: asset('${rel(l.file)}'), tiling: 4${normalRaws[i] ? ", normalScale: 1" : ""} },`),
|
|
166
|
+
`], normals: [ ${result.normals.map((f) => `asset('${rel(f)}')`).join(", ")} ]`,
|
|
167
|
+
].join("\n")
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
@@ -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
|
+
}
|