feat: add asset import services

This commit is contained in:
simoleo89
2026-07-12 19:12:25 +02:00
parent 4b596226e0
commit 3497df9dfd
46 changed files with 5383 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { inflateSync } from 'node:zlib'
export interface DecodedPng {
width: number
height: number
/** RGBA8888, row-major, length = width * height * 4 */
rgba: Buffer
}
function paeth(a: number, b: number, c: number): number {
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
if (pa <= pb && pa <= pc) return a
if (pb <= pc) return b
return c
}
function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void {
const n = cur.length
switch (filter) {
case 0:
break
case 1:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + (i >= bpp ? cur[i - bpp] : 0)) & 0xff
break
case 2:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + prev[i]) & 0xff
break
case 3:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xff
}
break
case 4:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
const c = i >= bpp ? prev[i - bpp] : 0
cur[i] = (cur[i] + paeth(a, prev[i], c)) & 0xff
}
break
default:
throw new Error(`unknown PNG filter ${filter}`)
}
}
/**
* Minimal PNG decoder for 8-bit non-interlaced images (color type 6/RGBA,
* 2/RGB, 0/grayscale). Enough to read Nitro spritesheet PNGs (RGBA8888).
* Returns RGBA pixels. Throws on unsupported formats.
*/
export function decodePng(buf: Buffer): DecodedPng {
if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
throw new Error('not a PNG')
}
let off = 8
let width = 0
let height = 0
let bitDepth = 0
let colorType = 0
let interlace = 0
const idat: Buffer[] = []
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off)
const type = buf.toString('ascii', off + 4, off + 8)
const data = buf.subarray(off + 8, off + 8 + len)
off += 12 + len // length(4) + type(4) + data + crc(4)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]
colorType = data[9]
interlace = data[12]
} else if (type === 'IDAT') {
idat.push(data)
} else if (type === 'IEND') {
break
}
}
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`)
if (interlace !== 0) throw new Error('interlaced PNG not supported')
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1
if (channels === -1) throw new Error(`unsupported PNG color type ${colorType}`)
const raw = inflateSync(Buffer.concat(idat))
const stride = width * channels
const planar = Buffer.alloc(height * stride)
let prev = Buffer.alloc(stride)
let rp = 0
for (let y = 0; y < height; y++) {
const filter = raw[rp++]
const cur = planar.subarray(y * stride, y * stride + stride)
raw.copy(cur, 0, rp, rp + stride)
rp += stride
unfilter(filter, cur, prev, channels)
prev = cur
}
if (channels === 4) return { width, height, rgba: planar }
const rgba = Buffer.alloc(width * height * 4)
for (let i = 0, j = 0; i < width * height; i++) {
if (channels === 3) {
rgba[j++] = planar[i * 3]
rgba[j++] = planar[i * 3 + 1]
rgba[j++] = planar[i * 3 + 2]
} else {
const g = planar[i]
rgba[j++] = g
rgba[j++] = g
rgba[j++] = g
}
rgba[j++] = 255
}
return { width, height, rgba }
}
/** Crop a w×h RGBA region at (x,y) out of a sheetW-wide RGBA buffer. */
export function cropRgba(
rgba: Buffer,
sheetW: number,
x: number,
y: number,
w: number,
h: number,
): Buffer {
const out = Buffer.alloc(w * h * 4)
for (let row = 0; row < h; row++) {
const src = ((y + row) * sheetW + x) * 4
rgba.copy(out, row * w * 4, src, src + w * 4)
}
return out
}