Files
SpineViewer/thumbnail/spine.js
T
2026-09-08 18:42:08 +08:00

228 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v4';
/** 发现配套资源;带重试,以应对 Eagle 导入时“多文件分批写入库”的时序问题。
* 同伴文件(骨骼/图集)可能在 Atlas 的缩略图生成后才落盘,故需等待。
* 一旦发现即立即返回(不会傻等满窗口),窗口仅作为最坏情况的上限。
* 采用 10 次 × 150ms ≈ 1.5 秒;间隔更密、最快感知同伴落盘。 */
async function discoverWithRetry(src, retries = 10, delay = 150) {
let assets = null;
for (let i = 0; i < retries; i++) {
assets = utils.discoverAssets(src);
if (assets.skeletonPath && assets.atlasPath) return assets;
await sleep(delay);
}
return assets;
}
/** 渲染缩略图;失败时重试一次(应对偶发渲染错误) */
async function renderWithRetry(opts) {
try {
return await render.renderThumbnail(opts);
} catch (e) {
log('renderThumbnail 首次失败: ' + (e && e.message) + ',重试...');
await sleep(150);
return await render.renderThumbnail(opts);
}
}
/**
* Eagle 格式扩展缩略图处理函数。
* 参数:{ src, dest, item }
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
* - dest : 需要生成的缩略图 PNG 路径
* - item : 当前 item 对象(可更新宽高)
*/
module.exports = async ({ src, dest, item }) => {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
try {
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
} catch (e) { /* ignore */ }
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
if (item && item.isDeleted) {
log(' skip: item.isDeleted=true');
return resolve(item);
}
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
const srcExt = utils.extOf(path.basename(src));
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
await copyImageAsThumb(src, dest);
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
return resolve(item);
}
const assets = await discoverWithRetry(src);
// 找不到配套 Spine 资源
if (!assets.skeletonPath || !assets.atlasPath) {
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
await writePlaceholder(dest, path.basename(src, utils.extOf(path.basename(src)) || ''));
item.width = 400; item.height = 400;
return resolve(item);
}
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
await writePlaceholder(dest, path.basename(assets.skeletonPath, path.extname(assets.skeletonPath)));
item.width = 400; item.height = 400;
return resolve(item);
}
// 渲染首帧到 PNG
const png = await renderWithRetry({
skeletonPath: assets.skeletonPath,
atlasPath: assets.atlasPath,
texturePaths: assets.texturePaths,
version,
runtimeDir,
size: 400,
});
fs.writeFileSync(dest, png);
try {
const size = imageSizeOf(dest);
item.width = size.width || 400;
item.height = size.height || 400;
} catch (e) { /* ignore */ }
return Promise.resolve(item);
} catch (err) {
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
try {
await writePlaceholder(dest, 'Spine');
item.width = 400;
item.height = 400;
return resolve(item);
} catch (e2) {
// 最后兜底:写入一张最小 1x1 PNG
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
item.width = 400; item.height = 400;
return resolve(item);
}
}
};
function resolve(item) {
return item;
}
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
async function copyImageAsThumb(src, dest) {
const ext = path.extname(src).toLowerCase();
if (ext === '.png') {
fs.copyFileSync(src, dest);
return;
}
if (render.canvasLib) {
try {
const img = await render.canvasLib.loadImage(src);
const w = img.width, h = img.height;
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
fs.copyFileSync(src, dest);
}
/** 读取 PNG 宽高(仅支持 PNG 头部) */
function imageSizeOf(file) {
const buf = fs.readFileSync(file);
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };
}
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
async function writePlaceholder(dest, text) {
const w = 400, h = 400;
// 深色底
const pixels = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
}
}
// 简单的斜线纹理,让占位图不单调
for (let i = 0; i < w + h; i += 8) {
for (let k = 0; k < 3; k++) {
const x = Math.min(w - 1, i - k);
const y = Math.min(h - 1, k);
const idx = (y * w + x) * 4;
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
}
}
fs.writeFileSync(dest, encodePng(w, h, pixels));
}
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
function encodePng(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const ihdrChunk = chunk('IHDR', ihdr);
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter type 0
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
const idat = zlib.deflateSync(raw);
const idatChunk = chunk('IDAT', idat);
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function crc32(buf) {
let c, crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 0xFF;
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}