feat: add asset import services
This commit is contained in:
1 parent
4b596226e0
commit
3497df9dfd
46 files changed
+5383
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodePng } from '@/lib/services/swf/nitro-builder'
|
||||
import { cropRgba, decodePng } from './png-decode'
|
||||
|
||||
describe('png-decode', () => {
|
||||
it('round-trips an RGBA image through encodePng → decodePng', () => {
|
||||
const w = 3
|
||||
const h = 2
|
||||
const rgba = Buffer.from([
|
||||
// row 0
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
|
||||
// row 1
|
||||
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120,
|
||||
])
|
||||
const png = encodePng(w, h, rgba)
|
||||
const dec = decodePng(png)
|
||||
expect(dec.width).toBe(w)
|
||||
expect(dec.height).toBe(h)
|
||||
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
|
||||
})
|
||||
|
||||
it('decodes a larger gradient losslessly', () => {
|
||||
const w = 16
|
||||
const h = 16
|
||||
const rgba = Buffer.alloc(w * h * 4)
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
rgba[i * 4] = i % 256
|
||||
rgba[i * 4 + 1] = (i * 3) % 256
|
||||
rgba[i * 4 + 2] = (i * 7) % 256
|
||||
rgba[i * 4 + 3] = 255
|
||||
}
|
||||
const dec = decodePng(encodePng(w, h, rgba))
|
||||
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
|
||||
})
|
||||
|
||||
it('crops a sub-region', () => {
|
||||
// 2x2 sheet, crop the bottom-right 1x1 pixel
|
||||
const sheet = Buffer.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4])
|
||||
const cropped = cropRgba(sheet, 2, 1, 1, 1, 1)
|
||||
expect([...cropped]).toEqual([4, 4, 4, 4])
|
||||
})
|
||||
|
||||
it('rejects non-PNG input', () => {
|
||||
expect(() => decodePng(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toThrow('not a PNG')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user