From deaefa6f8fe512454f49272ef63faf6f09232fe0 Mon Sep 17 00:00:00 2001 From: tianmo Date: Wed, 9 Sep 2026 09:58:01 +0800 Subject: [PATCH] =?UTF-8?q?1.1.0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/spine-utils.js | 57 +++++++++++--- thumbnail/spine.js | 191 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 15 deletions(-) diff --git a/js/spine-utils.js b/js/spine-utils.js index 760a25a..660aa27 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -15,6 +15,7 @@ const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json']; const ATLAS_EXTS = ['.atlas', '.atlas.txt']; const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp']; const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS); +const fileIndexCache = new Map(); // Spine 导出常见的后缀,匹配时归一化掉 const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i; @@ -319,6 +320,22 @@ function collectSpineFiles(roots, validFolders) { return out; } +/** + * 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个 + * 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉 + * 正在落盘的新文件。 + */ +function collectSpineFilesCached(filePath, roots) { + const libRoot = findLibraryRoot(filePath); + const key = libRoot || path.dirname(path.dirname(filePath)); + const now = Date.now(); + const cached = fileIndexCache.get(key); + if (cached && now - cached.time < 180) return cached.files; + const files = collectSpineFiles(roots, null); + fileIndexCache.set(key, { time: now, files }); + return files; +} + /** * 修正不可靠的源路径。 * Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录 @@ -362,6 +379,7 @@ function discoverAssets(filePath, options) { options = options || {}; const strictStem = !!options.strictStem; const preferCompatibleJson = !!options.preferCompatibleJson; + const allowCrossBatch = !!options.allowCrossBatch; filePath = resolveRealSource(filePath); const selected = path.basename(filePath); const selectedStem = normalizeStem(selected); @@ -375,7 +393,14 @@ function discoverAssets(filePath, options) { // folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。 const validFolders = getValidFolders(filePath); let files = collectSpineFiles(roots, validFolders); - if (strictStem) files = collectSpineFiles(roots, null); + if (strictStem) { + const allFiles = collectSpineFilesCached(filePath, roots); + if (allowCrossBatch && validFolders && validFolders.size) { + files = allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id))); + } else { + files = allFiles; + } + } const skeletonFiles = files.filter((f) => isSkeletonFile(f.name)); const atlasFiles = files.filter((f) => isAtlasFile(f.name)); @@ -403,11 +428,9 @@ function discoverAssets(filePath, options) { btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER, }; }); - ranked.sort((a, b) => - a.importDelta - b.importDelta || - b.sameFolder - a.sameFolder || - a.btimeDelta - b.btimeDelta || - a.c.fullPath.localeCompare(b.c.fullPath) + ranked.sort((a, b) => allowCrossBatch + ? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath)) + : (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath)) ); return ranked[0].c; }; @@ -463,7 +486,7 @@ function discoverAssets(filePath, options) { f.name.toLowerCase().endsWith('.json') && normalizeStem(f.name) === stem && familyOfSkel(f) === familyOfSkel(skeleton) && - (!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000) + (!strictStem || allowCrossBatch || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000) ); const jsonFallback = selectBest(jsonCands, skeleton.fullPath); if (jsonFallback) skeleton = jsonFallback; @@ -509,7 +532,7 @@ function discoverAssets(filePath, options) { // 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差 // 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。 - if (strictStem && skeleton && atlas) { + if (strictStem && !allowCrossBatch && skeleton && atlas) { const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath)); if (pairDelta > 30000) { if (isAtlasFile(selected)) skeleton = null; @@ -535,7 +558,7 @@ function discoverAssets(filePath, options) { let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); let hit = selectBest(cands, atlas.fullPath); - if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null; + if (strictStem && !allowCrossBatch && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null; if (hit) texturePaths[pn] = hit.fullPath; } } @@ -543,6 +566,21 @@ function discoverAssets(filePath, options) { const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem); const skelLower = skeleton ? skeleton.name.toLowerCase() : ''; const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes'); + const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null); + const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0; + const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0; + const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) => + normalizeStem(f.name) === base && + familyOfSkel(f) === chosenFamily && + Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= 30000 + ).map((f) => f.fullPath) : []; + const relatedAtlasPaths = atlas ? atlasFiles.filter((f) => + normalizeStem(f.name) === base && + familyOfAtlas(f) === chosenFamily && + Math.abs(importTimeOf(f.fullPath) - atlasTime) <= 30000 + ).map((f) => f.fullPath) : []; + const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))] + .filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp)); return { skeletonPath: skeleton ? skeleton.fullPath : null, @@ -555,6 +593,7 @@ function discoverAssets(filePath, options) { atlasCandidates: atlasFiles.map((f) => f.name), atlasCandidatePaths: atlasFiles.map((f) => f.fullPath), textureCandidates: textureFiles.map((f) => f.name), + relatedDataPaths, }; } diff --git a/thumbnail/spine.js b/thumbnail/spine.js index 4d208db..cad89f8 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -1,5 +1,8 @@ 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'); @@ -13,9 +16,19 @@ function log(msg) { } 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 = 'v6'; +const VERSION = 'v7'; + +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) { @@ -43,12 +56,17 @@ function assetSignature(assets) { * folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把 * 半截 atlas 或旧库中的同名骨骼渲染成占位图。 */ -async function discoverWithRetry(src, retries = 48, delay = 250) { +async function runDiscovery(src, retries, delay) { let assets = null; let previousSignature = null; let stableCount = 0; for (let i = 0; i < retries; i++) { - assets = utils.discoverAssets(src, { strictStem: true, preferCompatibleJson: true }); + // 前 5 秒只接受同批资源;之后允许在同一个 Eagle 文件夹内补齐分批拖入的链路。 + assets = utils.discoverAssets(src, { + strictStem: true, + preferCompatibleJson: true, + allowCrossBatch: i >= Math.min(20, retries - 1), + }); const signature = assetSignature(assets); if (signature && signature === previousSignature) stableCount += 1; else stableCount = signature ? 1 : 0; @@ -59,6 +77,36 @@ async function discoverWithRetry(src, retries = 48, delay = 250) { return assets; } +/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */ +async function discoverWithRetry(src, retries = 48, delay = 250) { + 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); + } +} + +/** 同一份稳定资源只渲染一次,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 } @@ -92,9 +140,10 @@ module.exports = async ({ src, dest, item }) => { let assets = await discoverWithRetry(src); - // 找不到配套 Spine 资源 - if (!assets.skeletonPath || !assets.atlasPath) { + // 找不到完整 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); @@ -112,6 +161,7 @@ module.exports = async ({ src, dest, item }) => { 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); } @@ -119,6 +169,7 @@ module.exports = async ({ src, dest, item }) => { if (!runtimeDir) { if (await writeAtlasFallback(assets, dest)) { + await refreshRelatedThumbnails(assets, dest, item && item.id); item.width = 400; item.height = 400; return resolve(item); } @@ -133,7 +184,7 @@ module.exports = async ({ src, dest, item }) => { let lastRenderError = null; for (let attempt = 0; attempt < 3; attempt++) { try { - png = await render.renderThumbnail({ + png = await renderThumbnailShared(assets, { skeletonPath: assets.skeletonPath, atlasPath: assets.atlasPath, texturePaths: assets.texturePaths, @@ -156,6 +207,7 @@ module.exports = async ({ src, dest, item }) => { 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); } @@ -163,6 +215,7 @@ module.exports = async ({ src, dest, item }) => { } fs.writeFileSync(dest, png); + await refreshRelatedThumbnails(assets, dest, item && item.id); try { const size = imageSizeOf(dest); @@ -192,6 +245,132 @@ 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; + // 最长观察约 10 分钟,覆盖大型文件夹分批落盘;每个资源组只有一个任务。 + for (let i = 0; i < 300; i++) { + await sleep(2000); + 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); }); +} + /** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */ async function copyImageAsThumb(src, dest) { const ext = path.extname(src).toLowerCase();