texturepackerify
Version:
Tool to pack textures into atlases
425 lines (356 loc) • 16 kB
JavaScript
;
const fs = require("fs/promises");
const path = require("path");
const pack = require('./pack-atlas');
const filesHelper = require('./utils/files-helper');
const promiseUtils = require('./utils/promise-utils');
const log = require('./utils/log');
const tempDir = ".texturepackerify";
const availableFormats = ["png", "jpeg", "webp", "avif"];
let oldHash = null;
let newHash = null;
let force = false;
let inputDir = "";
let outputDir = "";
let hashPath = "";
let scales = null;
let enableLogs = true;
let onProgressCallback = null;
let appendFileHash = false;
let appendTextureFormat = false;
let textureFormat = "";
let startTime;
const atlasNameMaxHashLength = 22;
let defaultAtlasConfig = {
extraSpace: 2,
border: 0,
alphaThreshold: 1,
removeAlpha: false,
jpeg: false,
extrude: false,
pot: true,
square: false,
spriteExtensions: true,
animations: false,
allowTrim: true,
allowRotation: true,
maxWidth: 4096,
maxHeight: 4096
};
let formatConfig = null;
/**
* Parameters texture formats.
* @typedef {Object} FormatConfig
* @property {Object} [jpeg] - Configuration options for `jpeg` texture format.
* @property {number} [jpeg.quality=80] - Specifies compression quality (1-100).
* @property {Object} [webp] - Configuration options for `webp` texture format.
* @property {number} [webp.quality=80] - Specifies compression quality (1-100).
* @property {number} [webp.alphaQuality=80] - Specifies compression quality for alpha channel (0-100).
* @property {Object} [avif] - Configuration options for `avif` texture format.
* @property {number} [avif.quality=80] - Specifies compression quality (1-100).
*/
/**
* Parameters for the default atlas configuration.
* @typedef {Object} AtlasConfig
* @property {number} [extraSpace=2] - Space (in pixels) between texture frames.
* @property {number} [border=0] - Border (in pixels) around the atlas texture.
* @property {boolean} [jpeg=false] - Force to use `jpeg` instead of `png` for texture format.
* @property {boolean} [removeAlpha=false] - Remove alpha channel.
* @property {boolean|Array} [extrude=false] - Expand image borders by one pixel in all directions. Accepts `true`, `false`, or an array of IDs.
* @property {boolean} [pot=true] - Force the atlas size to be a power of two (e.g., 256x256, 512x512).
* @property {boolean} [square=false] - Force the atlas size to be square.
* @property {boolean} [animations=false] - Enable animation parsing.
* @property {number} [alphaThreshold=1] - Alpha threshold value for trimming transparent areas.
* @property {boolean} [spriteExtensions=true] - Append extensions (e.g., `.png`) to sprite frame names.
* @property {boolean} [allowRotation=true] - Allows sprite frame rotation.
* @property {boolean} [allowTrim=true] - Enables the trimming of transparent pixels.
* @property {number} [maxWidth=4096] - Maximum texture width (in pixels).
* @property {number} [maxHeight=4096] - Maximum texture height (in pixels).
*/
/**
* Packs textures into atlases using the TexturePackerify tool.
* @async
* @function
* @param {Object} config - Pack config.
* @param {string} [config.inputDir="./"] - The directory containing input atlas folders.
* @param {string} [config.outputDir=inputDir] - The directory for the generated output. Default: Same as `inputDir`.
* @param {string} [config.hashPath="./.texturepackerify/hash.json"] - The path to the hash file for caching.
* @param {boolean} [config.force=false] - Specifies whether to force a rebuild of the atlases.
* @param {number[]} [config.scales=[1]] - An array of scale factors for generating mipmaps.
* @param {boolean} [config.appendFileHash=false] - Specifies whether to add file hash to output file names.
* @param {boolean} [config.appendTextureFormat=false] - Specifies whether to add texture format to output atlas names.
* @param {"png"|"jpeg"|"webp"|"avif"} [config.textureFormat="png"] - Specifies the texture output format.
* @param {boolean} [config.enableLogs=true] - Enables or disables console logs.
* @param {Function} [config.onProgress] - Callback for progress updates.
* @param {AtlasConfig} [config.defaultAtlasConfig] - Overrides for the default configuration of atlas settings.
* @param {FormatConfig} [config.formatConfig] - Overrides for the default configuration of atlas settings.
* @returns {Promise<void>}
* @example
*
* const texturepackerify = require("texturepackerify");
* const packAtlases = async () => {
* await texturepackerify.pack({
* inputDir: "./atlases",
* outputDir: "./public/assets",
* textureFormat: "webp",
* scales: [1, 0.5],
* defaultAtlasConfig: {
* pot: false,
* animations: true,
* border: 2,
* },
* formatConfig: {
* webp: { quality: 80 },
* },
* });
* };
* packAtlases();
*/
const packAtlases = async (config = {}) => {
oldHash = {};
newHash = null;
if (config.inputDir !== undefined && typeof config.inputDir !== 'string') {
console.error("Pack config error: 'config.inputDir' must be a string");
return;
}
inputDir = path.normalize(config.inputDir ?? ".");
const assetsExist = await filesHelper.isFileExists(inputDir);
if (!assetsExist) {
console.error(`No input folder: ${inputDir}`);
return;
}
if (config.outputDir !== undefined && typeof config.outputDir !== 'string') {
console.error("Pack config error: 'config.outputDir' must be a string");
return;
}
outputDir = path.normalize(config.outputDir ?? inputDir);
if (config.hashPath !== undefined && typeof config.hashPath !== 'string') {
console.error("Pack config error: 'config.hashPath' must be a string");
return;
}
hashPath = tempDir;
if (config.hashPath) {
hashPath = path.normalize(config.hashPath);
}
if (!hashPath.endsWith(".json")) {
hashPath = path.join(hashPath, "hash.json");
}
if (config.force !== undefined && typeof config.force !== 'boolean') {
console.error("Pack config error: 'config.force' must be a boolean");
return;
}
force = config.force ?? false;
scales = config.scales ?? [1];
if (scales) {
if (!Array.isArray(scales)) {
console.error("Pack config error: 'config.scales' must be an array");
return;
}
if (scales.length === 0) {
console.error("Pack config error: 'config.scales' cannot be empty");
return;
}
for (const scale of scales) {
if (typeof scale !== 'number') {
console.error("Pack config error: All elements in 'config.scales' array must be a numbers");
return;
}
}
scales = [...new Set(scales)];
}
if (config.enableLogs !== undefined && typeof config.enableLogs !== 'boolean') {
console.error("Pack config error: 'config.enableLogs' must be a boolean");
return;
}
enableLogs = config.enableLogs ?? true;
if (config.appendFileHash !== undefined && typeof config.appendFileHash !== 'boolean') {
console.error("Pack config error: 'config.appendFileHash' must be a boolean");
return;
}
appendFileHash = config.appendFileHash ?? false;
if (config.appendTextureFormat !== undefined && typeof config.appendTextureFormat !== 'boolean') {
console.error("Pack config error: 'config.appendTextureFormat' must be a boolean");
return;
}
appendTextureFormat = config.appendTextureFormat ?? false;
if (config.textureFormat !== undefined && !availableFormats.includes(config.textureFormat)) {
console.error(`Pack config error: 'config.textureFormat' must one of the following string values: ${availableFormats.map(v => `'${v}'`).join(" | ")} `);
return;
}
textureFormat = config.textureFormat ?? "png";
if (config.onProgress !== undefined && !(config.onProgress instanceof Function)) {
console.error("Pack config error: 'config.onProgress' must be a Function");
return;
}
onProgressCallback = config.onProgress;
defaultAtlasConfig = Object.assign({}, defaultAtlasConfig, config.defaultAtlasConfig);
formatConfig = config.formatConfig;
startTime = performance.now();
await filesHelper.createDirectoryRecursive(outputDir);
await filesHelper.createDirectoryRecursive(path.dirname(hashPath));
let packList = await getPackList();
const totalSteps = packList.length * scales.length;
let finished = 0;
onProgress(finished, totalSteps);
await getHash();
try {
await promiseUtils.sequence(packList.map(folderName => {
return async () => {
const atlasDir = path.join(inputDir, folderName);
const files = (await fs.readdir(atlasDir));
const atlasResourceFiles = files.filter(value => value.endsWith(".png") || value.endsWith(".json"));
let skip = true;
if (force) {
skip = false;
}
if (atlasResourceFiles.length > 0) {
atlasResourceFiles.forEach(file => {
let fileId = path.join(atlasDir, file);
if (oldHash[fileId] === undefined || oldHash[fileId] !== newHash[fileId]) {
oldHash[fileId] = newHash[fileId];
skip = false;
}
});
}
for (let hashId in oldHash) {
if (hashId.indexOf(atlasDir) > -1 && newHash[hashId] === undefined) {
oldHash[hashId] = undefined;
skip = false;
}
}
await promiseUtils.sequence(scales.map(scale => {
return async () => {
const atlasConfig = await getAtlasConfig(atlasDir);
const outputTextureFormat = ((atlasConfig.jpeg || atlasConfig.jpg) && textureFormat === "png") ? "jpeg" : textureFormat;
const atlasName = folderName;
const atlasScaleStr = (scales.length === 1 && scales[0] === 1) ? "" : `@${scale}x`;
const atlasMatchFiles = await getAtlasMatchFiles(outputDir, atlasName, atlasScaleStr, outputTextureFormat);
if (atlasMatchFiles.length < 2) {
skip = false;
}
if (!skip) {
if (atlasMatchFiles.length) {
await Promise.all(atlasMatchFiles.map(file => fs.unlink(path.join(outputDir, file))));
}
const packConfig = {
atlasDir,
hash: newHash,
scale,
atlasConfig,
outputDir,
atlasName,
atlasScaleStr,
appendFileHash,
appendTextureFormat,
atlasNameMaxHashLength,
textureFormat: outputTextureFormat,
formatConfig
};
await pack(packConfig);
}
await filesHelper.saveHash(hashPath, oldHash);
onProgress(++finished, totalSteps);
};
}));
};
}));
} catch (error) {
console.error(error.message);
//console.error(error.stack);
return;
}
};
const getPackList = async () => {
let packList = [];
const skipList = ["node_modules",];
const files = await fs.readdir(inputDir);
const resolvedOutputPath = path.resolve(outputDir);
const resolvedTempPath = path.resolve(tempDir);
for (const file of files) {
const currentPath = path.join(inputDir, file);
const stats = await fs.lstat(currentPath);
if (stats.isDirectory() && !skipList.includes(file)) {
const resolvedCurrentPath = path.resolve(file);
if (resolvedCurrentPath !== resolvedOutputPath && resolvedCurrentPath !== resolvedTempPath) {
const resources = (await fs.readdir(currentPath)).filter(file => file.endsWith(".png"));
if (resources.length > 0) {
packList.push(file);
}
}
}
}
return packList;
};
const onProgress = (finished, total) => {
if (onProgressCallback) {
onProgressCallback({ finished, total });
}
if (enableLogs) {
if (finished > 0) {
log.clearLastLine();
}
const isFinished = finished === total;
const time = isFinished ? `in ${Math.round(performance.now() - startTime)} ms` : "";
const progress = isFinished ? log.color("[complete]", "green") : `[${Math.floor(finished / total * 100)}%]`;
log.trace("packing", log.bold(`${inputDir}`), log.bold(progress), time);
}
};
const getHash = async () => {
const list = await getFiles();
newHash = {};
for (const filePath of list) {
const data = await fs.readFile(filePath);
const hash = await filesHelper.getDataHash(data);
newHash[filePath] = hash;
}
oldHash = await filesHelper.loadHash(hashPath);
};
const getAtlasMatchFiles = async (atlasPath, atlasName, atlasScaleStr, atlasExtension) => {
const outputList = [];
const filesList = (await fs.readdir(atlasPath));
const jsonList = filesList.filter(path => path.endsWith(".json"));
const texturesList = filesList.filter(path => path.endsWith(`.${atlasExtension}`));
for (const file of jsonList) {
if (matchFileName(atlasName, atlasScaleStr, appendTextureFormat ? `${atlasExtension}.json` : "json", file)) {
outputList.push(file);
}
}
for (const file of texturesList) {
if (matchFileName(atlasName, atlasScaleStr, atlasExtension, file)) {
outputList.push(file);
}
}
return outputList;
};
const matchFileName = (atlasName, atlasScaleStr, atlasExtension, matchStr) => {
return appendFileHash ?
matchStr.match(`^${atlasName}\-([A-Za-z0-9_-]{${atlasNameMaxHashLength}})${atlasScaleStr}\.${atlasExtension}$`) :
matchStr === `${atlasName}${atlasScaleStr}.${atlasExtension}`;
};
const getAtlasConfig = async (atlasDir) => {
const configPath = path.join(atlasDir, "config.json");
let configData = {};
try {
const data = await fs.readFile(configPath, "utf-8");
configData = JSON.parse(data);
} catch (err) {
//returning empty config;
}
return { ...defaultAtlasConfig, ...configData };
};
const getFiles = async () => {
const files = await fs.readdir(inputDir);
let list = [];
for (const file of files) {
const filePath = path.join(inputDir, file);
const stats = await fs.lstat(filePath);
if (stats.isDirectory()) {
const subFiles = await filesHelper.getFilesRecursive(path.join(inputDir, file));
list = list.concat(subFiles.filter(id => id.endsWith(".png") || id.endsWith(".json")));
}
}
return list;
};
module.exports = {
pack: packAtlases
};