UNPKG

@bscotch/sprite-source

Version:

Art pipeline scripting module for GameMaker sprites.

225 lines 8.89 kB
import { __decorate, __metadata } from "tslib"; import { Pathy, statSafe } from '@bscotch/pathy'; import { sequential } from '@bscotch/utility'; import { Image } from 'image-js'; import { retryOptions } from './constants.js'; import { getPngSize } from './utility.js'; export class SpriteFrame { path; _size; _bbox; _image; _masks = {}; _checksum; constructor(path) { this.path = path; } clearCache() { this._size = undefined; this._bbox = undefined; this._image = undefined; this._checksum = undefined; this._masks = {}; } async updateCache(cache) { const lastChanged = (await statSafe(this.path, retryOptions)).mtime.getTime(); const needsUpdate = (cache.frames[this.path.relative]?.changed || 0) !== lastChanged; if (!needsUpdate) return cache.frames[this.path.relative]; const [size] = await Promise.all([this.getSize()]); cache.frames[this.path.relative] = { changed: lastChanged, checksum: '', // To be batch-computed later height: size.height, width: size.width, }; return cache.frames[this.path.relative]; } async getImage() { if (this._image) { return this._image; } this._image = await Image.load(this.path.absolute); return this._image; } async getSize() { if (this._size) { return { ...this._size }; } this._size = await getPngSize(this.path); return this._size; } async getForegroundMask(foregroundMinAlphaFraction) { const alphaKey = `${foregroundMinAlphaFraction}`; if (this._masks[alphaKey]) { return this._masks[alphaKey]; } const image = await this.getImage(); const threshold = foregroundMinAlphaFraction || 1 / Math.pow(2, image.bitDepth); this._masks[alphaKey] = image .getChannel(image.channels - 1) .mask({ threshold }); return this._masks[alphaKey]; } async getBoundingBox(padding = 1) { if (this._bbox) { return { ...this._bbox }; } const foreground = await this.getForegroundMask(); let left = Infinity; let right = -Infinity; let top = Infinity; let bottom = -Infinity; for (let x = 0; x < foreground.width; x++) { for (let y = 0; y < foreground.height; y++) { if (foreground.getBitXY(x, y)) { left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); } } } padding = Math.round(padding); if (padding > 0) { top = Math.max(top - padding, 0); left = Math.max(left - padding, 0); right = Math.min(right + padding, foreground.width - 1); bottom = Math.min(bottom + padding, foreground.height - 1); } this._bbox = { left: left == Infinity ? 0 : left, right: right == -Infinity ? foreground.width - 1 : right, top: top == Infinity ? 0 : top, bottom: bottom == -Infinity ? foreground.height - 1 : bottom, }; return { ...this._bbox }; } async crop(bbox) { const img = await this.getImage(); const cropBox = { x: bbox.left, y: bbox.top, width: bbox.right - bbox.left + 1, height: bbox.bottom - bbox.top + 1, }; this.clearCache(); this._image = img.crop(cropBox); } async bleed() { const img = await this.getImage(); if (!img.alpha) { return; } const maxPixelValue = Math.pow(2, img.bitDepth); const bleedMaxAlpha = Math.ceil(0.02 * maxPixelValue); const transparentBlackPixel = [...Array(img.channels)].map(() => 0); // Create a mask from the background (alpha zero) and then erode it by a few pixels. // Add a mask from the foreground (alpha > 0) // Invert to get the background pixels that need to be adjusted // Set the color of those pixels to the the color of the nearest foreground, and the alpha // to something very low so that it mostly isn't visible but won't be treated as background downstream const foreground = await this.getForegroundMask((bleedMaxAlpha + 1) / maxPixelValue); const expandedForeground = foreground.dilate({ kernel: [ [1, 1, 1], [1, 1, 1], [1, 1, 1], ], }); const isInForeground = (x, y) => foreground.getBitXY(x, y); const isInExpandedForeground = (x, y) => expandedForeground.getBitXY(x, y); const isInOutline = (x, y) => isInExpandedForeground(x, y) && !isInForeground(x, y); // There does not seem to be a way to combine masks in image-js, // but we don't really need to for the desired outcome. // Iterate over all pixels. Those in the expanded foreground but not in the foreground // should be set in the original image based on nearby non-background pixels for (let x = 0; x < img.width; x++) { for (let y = 0; y < img.height; y++) { if (isInOutline(x, y)) { const neighbors = []; for (let ax = x - 1; ax <= x + 1; ax++) { for (let ay = y - 1; ay <= y + 1; ay++) { if (ax == x && ay == y) { continue; } if (isInForeground(ax, ay)) { neighbors.push(img.getPixelXY(ax, ay)); } } } if (neighbors.length) { // average the colors const colorSamples = transparentBlackPixel.map(() => []); for (const neighbor of neighbors) { for (let channel = 0; channel < img.channels; channel++) { colorSamples[channel].push(neighbor[channel]); } } const newColor = colorSamples.map((sample, idx) => { if (idx == img.channels - 1) { // Alpha should be 2% or half the min neighboring alpha const minAlpha = sample.reduce((min, value) => Math.min(min, value), Infinity); return Math.ceil(Math.min(minAlpha * 0.5, bleedMaxAlpha)); } else { // Use the average color return Math.round(sample.reduce((sum, value) => sum + value, 0) / sample.length); } }); img.setPixelXY(x, y, newColor); } } } } this.clearCache(); this._image = img; } async saveTo(path) { const image = await this.getImage(); await image.save(path.absolute); } } __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "getImage", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "getSize", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Number]), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "getForegroundMask", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "getBoundingBox", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "crop", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "bleed", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Pathy]), __metadata("design:returntype", Promise) ], SpriteFrame.prototype, "saveTo", null); //# sourceMappingURL=SpriteFrame.js.map