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; }