Files
SpineViewer/thumbnail/spine.js
T
2026-09-09 15:58:56 +08:00

567 lines
21 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 os = require('os');
const http = require('http');
const crypto = require('crypto');
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));
const discoveryJobs = new Map();
const backgroundJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v10';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
}
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
function assetSignature(assets) {
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
let atlasText = '';
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
const pages = utils.parseAtlasPages(atlasText);
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
const stats = [];
for (const file of paths) {
try {
const stat = fs.statSync(file);
if (!stat.isFile() || stat.size === 0) return null;
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
} catch (e) {
return null;
}
}
return stats.join('|');
}
/**
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
*/
async function runDiscovery(src, retries, delay) {
let assets = null;
let previousSignature = null;
let stableCount = 0;
for (let i = 0; i < retries; i++) {
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
preferCompatibleJson: true,
allowCrossBatch: false,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount >= 3) return assets;
if (i < retries - 1) await sleep(delay);
}
return assets;
}
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
async function discoverWithRetry(src, retries = 5, delay = 120) {
const key = coordinationKey(src);
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
const job = runDiscovery(src, retries, delay);
discoveryJobs.set(key, job);
try {
return await job;
} finally {
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
}
}
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
async function classifyJsonBriefly(src) {
let kind = 'pending';
for (let i = 0; i < 3; i++) {
kind = utils.classifySkeletonJson(src);
if (kind !== 'pending') return kind;
if (i < 2) await sleep(80);
}
return kind;
}
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
async function renderThumbnailShared(assets, options) {
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
if (renderJobs.has(key)) {
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
return renderJobs.get(key);
}
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
const job = render.renderThumbnail(options);
renderJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
}
}
/**
* 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);
}
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
// 普通 JSON 立即生成中性缩略图,不扫描资源库,也不启动后台补刷。
if (srcExt === '.json') {
const jsonKind = await classifyJsonBriefly(src);
if (jsonKind === 'other') {
log('普通 JSON,跳过 Spine 资源发现: ' + src);
await writeNeutralJsonPlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
}
let assets = await discoverWithRetry(src);
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
scheduleBackgroundReconcile(src);
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
let version = utils.detectVersion(assets.skeletonPath);
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
// 3.6/3.7 二进制没有对应解析器。若有同批 JSON,资源发现阶段已自动切换;
// 走到这里说明没有 JSON,直接显示 atlas 实际贴图,避免无意义地重复解析。
if (assets.isBinary && version && parseFloat(version) < 3.8) {
if (await writeAtlasFallback(assets, dest)) {
log('旧版二进制无 JSON,已使用 atlas 贴图缩略图: ' + src);
await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
}
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
let png = null;
let lastRenderError = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
png = await renderThumbnailShared(assets, {
skeletonPath: assets.skeletonPath,
atlasPath: assets.atlasPath,
texturePaths: assets.texturePaths,
version,
runtimeDir,
size: 400,
});
break;
} catch (e) {
lastRenderError = e;
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
if (attempt === 2) break;
await sleep(500);
assets = await discoverWithRetry(src, 4, 150);
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
version = utils.detectVersion(assets.skeletonPath);
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
}
}
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
throw lastRenderError || new Error('Spine 资源尚未写入完成');
}
fs.writeFileSync(dest, png);
await refreshRelatedThumbnails(assets, dest, item && item.id);
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);
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;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
function setEagleCustomThumbnail(itemId, thumbnailPath) {
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => setEagleCustomThumbnail(id, apiPath)));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
const key = coordinationKey(src);
if (backgroundJobs.has(key)) return;
const job = (async () => {
let previousSignature = null;
let stableCount = 0;
// 前几秒快速观察,随后降低频率;总计约两分钟。若更晚补拖第三个文件,
// 新文件自己的缩略图回调会再次触发同一资源链发现,无需常驻轮询十分钟。
for (let i = 0; i < 36; i++) {
const delay = i < 8 ? 1000 : (i < 20 ? 2500 : 5000);
await sleep(delay);
const assets = utils.discoverAssets(src, {
strictStem: true,
preferCompatibleJson: true,
allowCrossBatch: true,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount < 3) continue;
const tempPng = path.join(os.tmpdir(), 'spineviewer-reconcile-' + process.pid + '-' + Date.now() + '.png');
try {
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
let wrote = false;
if (assets.isBinary && version && parseFloat(version) < 3.8) {
wrote = await writeAtlasFallback(assets, tempPng);
} else if (runtimeDir) {
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(tempPng, png);
wrote = true;
} catch (e) {
wrote = await writeAtlasFallback(assets, tempPng);
}
}
if (wrote) {
await refreshRelatedThumbnails(assets, tempPng);
log('后台识别到完整资源链并补刷: ' + key);
}
} finally {
try { if (fs.existsSync(tempPng)) fs.unlinkSync(tempPng); } catch (e) { /* ignore */ }
}
return;
}
log('后台等待资源链超时: ' + key);
})();
backgroundJobs.set(key, job);
job.catch((e) => log('后台补刷异常: ' + ((e && e.message) || String(e))))
.finally(() => { if (backgroundJobs.get(key) === job) backgroundJobs.delete(key); });
}
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#202126';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#555963';
ctx.beginPath();
ctx.moveTo(112, 72);
ctx.lineTo(244, 72);
ctx.lineTo(288, 116);
ctx.lineTo(288, 328);
ctx.lineTo(112, 328);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#777c87';
ctx.beginPath();
ctx.moveTo(244, 72);
ctx.lineTo(244, 116);
ctx.lineTo(288, 116);
ctx.closePath();
ctx.fill();
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
const pixels = Buffer.alloc(size * size * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
}
fs.writeFileSync(dest, encodePng(size, size, pixels));
}
/** 将任意图片作为缩略图复制/转换为 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);
}
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
async function writeAtlasFallback(assets, dest) {
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
const texturePath = Object.values(assets.texturePaths).find((file) => {
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
});
if (!texturePath) return false;
try {
const img = await render.canvasLib.loadImage(texturePath);
const size = 400;
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, size, size);
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
await render.drawSpineBadge(ctx, size, size);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return true;
} catch (e) {
log('atlas 贴图回退失败: ' + (e && e.message));
return false;
}
}
/** 读取 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) {
const w = 400, h = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, w, h);
await render.drawSpineBadge(ctx, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through to pure JS encoder */ }
}
// 深色底
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;
}