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 renderJobs = new Map(); const refreshJobs = new Map(); /** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */ const VERSION = 'v14'; 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, allowCrossBatch: false, refreshIndex: i > 0, }); 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 (options) => { const { src, dest, item } = options; const changed = require('../js/eagle-compound.js').normalizeItem(src, item); // Eagle originally chooses the cache name before invoking the plugin. // Supply that requested output as well as the corrected compound-name cache. const canonical = changed && path.basename(dest).endsWith('_thumbnail.png') ? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest; const result = await generate({ ...options, deferRefresh: changed }); if (canonical !== dest && fs.existsSync(dest)) { if (render.canvasLib) { const image = await render.canvasLib.loadImage(dest); const canvas = render.canvasLib.createCanvas(image.width, image.height); canvas.getContext('2d').drawImage(image, 0, 0); fs.writeFileSync(canonical, await canvas.encode('webp', 90)); } else fs.copyFileSync(dest, canonical); } if (changed && item.id) { log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext); } const ext = utils.extOf(path.basename(src)); if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src); return result; }; async function generate({ src, dest, item, deferRefresh }) { 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)); // Eagle dispatches compound suffixes as txt/bytes. Only the complete // .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit. if (!srcExt) { if (/\.txt$/i.test(src)) { // Eagle's native text card requires item.text, even for an empty file. item.text = ''; const fd = fs.openSync(src, 'r'); try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); } finally { fs.closeSync(fd); } } await writeNeutralJsonPlaceholder(dest); item.width = 400; item.height = 400; return resolve(item); } 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 + ')'); 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); if (!runtimeDir) { if (await writeAtlasFallback(assets, dest)) { if (!deferRefresh) 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); if (!deferRefresh) 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); if (!deferRefresh) 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; } async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) { if (expectedFilename) { // Eagle's foreground may still hold the old name after a type change. // Do not let setCustomThumbnail persist that stale name back to metadata. const info = await new Promise((resolveInfo, rejectInfo) => { const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => { let body = ''; response.on('data', chunk => { body += chunk; }); response.on('end', () => { try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); } catch (error) { rejectInfo(error); } }); }); req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo); }); if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图'); } 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.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时'))); 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 false; const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))]; if (ids.length === 0) return true; 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) => { const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id); return setEagleCustomThumbnail(id, apiPath, source && path.basename(source)); })); const failed = results.filter((result) => result.status === 'rejected'); if (failed.length) throw failed[0].reason; log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']'); return true; } catch (e) { log('统一补刷失败: ' + ((e && e.message) || String(e))); return false; } finally { if (apiPath !== thumbnailPath) { try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ } } } })(); refreshJobs.set(key, job); try { return await job; } finally { setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000); } } /** * 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责 * 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。 */ function scheduleBackgroundReconcile(src) { try { require('../js/spine-reconcile-worker.js').enqueue(src); } catch (error) { log('无法启动后台补刷: ' + String(error)); } } // Called by a separate Node worker; Eagle closes the thumbnail window when // its callback resolves, so timers in that window cannot finish this work. module.exports.reconcileOnce = async (src, previousSignature) => { if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true }; const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true }); const signature = assetSignature(assets); if (!signature || signature !== previousSignature) return { signature, complete: false }; const version = utils.detectVersion(assets.skeletonPath); const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); if (!runtimeDir) return { signature, complete: true }; const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png'); try { const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 }); fs.writeFileSync(temp, png); const complete = await refreshRelatedThumbnails(assets, temp); return { signature, complete }; } catch (error) { log('后台补刷重试: ' + String(error)); return { signature, complete: false }; } finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } } }; /** 普通 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); if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath)); 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; }