From ef01cdd6eedc0a72faadd661f116e3915a6c749b Mon Sep 17 00:00:00 2001 From: tianmo Date: Thu, 10 Sep 2026 14:04:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + js/spine-reconcile-worker.js | 85 +++++++++++++++++++++++++++ js/spine-utils.js | 6 +- tests/compound-extensions.test.js | 7 ++- tests/reconcile-lifecycle.test.js | 44 ++++++++++++++ thumbnail/spine.js | 95 +++++++++++-------------------- 6 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 js/spine-reconcile-worker.js create mode 100644 tests/reconcile-lifecycle.test.js diff --git a/README.md b/README.md index fa88594..4694fcc 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ SpineViewer/ - JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。 - 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。 - 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。 + - Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。 --- diff --git a/js/spine-reconcile-worker.js b/js/spine-reconcile-worker.js new file mode 100644 index 0000000..e660029 --- /dev/null +++ b/js/spine-reconcile-worker.js @@ -0,0 +1,85 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const utils = require('./spine-utils'); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function enqueue(src) { + const library = utils.findLibraryRoot(src); + if (!library) return; // Standalone tests/previews do not need Eagle refresh. + const key = crypto.createHash('sha256').update(__dirname + library).digest('hex'); + const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key); + fs.mkdirSync(directory, { recursive: true }); + const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json'); + const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'; + fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 })); + fs.renameSync(temp, job); + // Electron's Node mode runs the local worker without opening another Eagle. + const child = spawn(process.execPath, [__filename, directory], { + detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + }); + child.on('error', error => { + try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} + }); + child.unref(); +} + +async function run(directory) { + const lock = path.join(directory, 'worker.lock'); + let fd; + try { fd = fs.openSync(lock, 'wx'); } + catch (error) { + if (error.code !== 'EEXIST') throw error; + let pid; + try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; } + if (!pid) return; // Another worker is still writing its lock. + try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; } + try { fs.unlinkSync(lock); } catch (_) { return; } + return run(directory); + } + fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd); + const snapshots = new Map(); + const nextChecks = new Map(); + const thumbnail = require('../thumbnail/spine'); + try { + let idle = 0; + while (idle < 2) { + await sleep(1000); // Allow the importing callback/window to finish. + const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json')); + if (!jobs.length) { idle++; continue; } + idle = 0; + for (const name of jobs) { + const file = path.join(directory, name); + let raw; + try { + raw = fs.readFileSync(file, 'utf8'); + const job = JSON.parse(raw); + if (Date.now() < (nextChecks.get(name) || 0)) continue; + const remaining = job.deadline - Date.now(); + nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000)); + const result = Date.now() > job.deadline ? { complete: true } + : await thumbnail.reconcileOnce(job.src, snapshots.get(name)); + snapshots.set(name, result.signature); + if (result.complete && fs.readFileSync(file, 'utf8') === raw) { + fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name); + } + } catch (error) { + if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); + } + } + } + } finally { + fs.unlinkSync(lock); + // Cover an enqueue racing the final idle check and lock release. + if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory); + } +} + +module.exports = { enqueue, run }; +if (require.main === module) run(process.argv[2]).catch(error => { + try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} + process.exitCode = 1; +}); diff --git a/js/spine-utils.js b/js/spine-utils.js index ee341e2..41e860e 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -359,14 +359,14 @@ function collectSpineFiles(roots, validFolders) { * 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉 * 正在落盘的新文件。 */ -function collectSpineFilesCached(filePath, roots) { +function collectSpineFilesCached(filePath, roots, refresh) { const libRoot = findLibraryRoot(filePath); // Outside an Eagle library, sibling asset folders are independent search // scopes. Sharing their parent as a cache key can return another skeleton. const key = libRoot || JSON.stringify([...roots].sort()); const now = Date.now(); const cached = fileIndexCache.get(key); - if (cached && now - cached.time < 1500) return cached.files; + if (!refresh && cached && now - cached.time < 1500) return cached.files; const files = collectSpineFiles(roots, null); fileIndexCache.set(key, { time: now, files }); return files; @@ -434,7 +434,7 @@ function discoverAssets(filePath, options) { const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000; let files = collectSpineFiles(roots, validFolders); if (strictStem) { - const allFiles = collectSpineFilesCached(filePath, roots); + const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex); if (allowCrossBatch && validFolders && validFolders.size) { files = allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id))); } else { diff --git a/tests/compound-extensions.test.js b/tests/compound-extensions.test.js index 408baca..8855380 100644 --- a/tests/compound-extensions.test.js +++ b/tests/compound-extensions.test.js @@ -30,5 +30,10 @@ try { assert.equal(assets.isBinary, true); assert.equal(utils.chooseRuntimeDir(utils.detectVersion(skeleton), true), version); } } - console.log('PASS: Eagle last-extension routing, case handling, ordinary-file exclusion and sibling cache isolation'); + const late = path.join(root, 'late'); fs.mkdirSync(late); + const lateAtlas = path.join(late, 'hero.atlas.txt'); fs.writeFileSync(lateAtlas, 'hero.png\nsize: 64,64\n'); + assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true }).skeletonPath, null); + const lateSkeleton = path.join(late, 'hero.skel.bytes'); fs.writeFileSync(lateSkeleton, Buffer.from('\0' + '3.8.1\0')); + assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true, refreshIndex: true }).skeletonPath, lateSkeleton); + console.log('PASS: Eagle routing, case handling, ordinary-file exclusion, cache isolation and late-arriving skeleton'); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/tests/reconcile-lifecycle.test.js b/tests/reconcile-lifecycle.test.js new file mode 100644 index 0000000..d85a676 --- /dev/null +++ b/tests/reconcile-lifecycle.test.js @@ -0,0 +1,44 @@ +'use strict'; +const assert = require('assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const { execFile } = require('child_process'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-worker-lifecycle-')); +const library = path.join(root, 'test.library'); +const source = path.join(library, 'images', 'test.info', 'hero.atlas.txt'); +const marker = path.join(root, 'completed'); +const worker = path.resolve(__dirname, '../js/spine-reconcile-worker.js'); +const thumbnail = path.resolve(__dirname, '../thumbnail/spine.js'); +const key = crypto.createHash('sha256').update(path.dirname(worker) + library).digest('hex'); +const queue = path.join(os.tmpdir(), 'spineviewer-reconcile', key); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +(async () => { + try { + fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'atlas'); + fs.writeFileSync(path.join(library, 'metadata.json'), '{"folders":[]}'); + // Isolate lifecycle behavior from rendering and the user's running Eagle API. + const preload = path.join(root, 'preload.cjs'); + fs.writeFileSync(preload, `require.cache[${JSON.stringify(thumbnail)}] = { exports: { + reconcileOnce: async (src, previous) => { + if (previous !== 'stable') return { signature: 'stable', complete: false }; + require('fs').appendFileSync(${JSON.stringify(marker)}, String(process.pid) + '\\n'); + return { complete: true }; + } + }};`); + await new Promise((resolve, reject) => execFile(process.execPath, ['-e', `require(${JSON.stringify(worker)}).enqueue(${JSON.stringify(source)})`], + { env: { ...process.env, NODE_OPTIONS: '--require=' + preload } }, error => error ? reject(error) : resolve())); + assert(!fs.existsSync(marker), 'launcher returns before reconciliation'); + const deadline = Date.now() + 15000; + while ((!fs.existsSync(marker) || fs.existsSync(path.join(queue, 'worker.lock'))) && Date.now() < deadline) await sleep(100); + assert(fs.existsSync(marker), 'worker completes after launching process has exited'); + assert(!fs.existsSync(path.join(queue, 'worker.lock')), 'worker releases lock and exits when idle'); + assert.equal(fs.readdirSync(queue).filter(name => name.endsWith('.json')).length, 0); + console.log('PASS: detached worker survives callback process exit, completes queued work and releases lock'); + } finally { + if (!fs.existsSync(path.join(queue, 'worker.lock'))) { + fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(queue, { recursive: true, force: true }); + } + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/thumbnail/spine.js b/thumbnail/spine.js index 131e038..ad4cbee 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -17,12 +17,11 @@ 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 = 'v13'; +const VERSION = 'v14'; function coordinationKey(src) { const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src)); @@ -66,6 +65,7 @@ async function runDiscovery(src, retries, delay) { assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: false, + refreshIndex: i > 0, }); const signature = assetSignature(assets); if (signature && signature === previousSignature) stableCount += 1; @@ -143,15 +143,9 @@ module.exports = async (options) => { } if (changed && item.id) { log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext); - // The item does not enter Eagle's item map until this callback returns. - // Reconcile afterwards, using the correctly named persistent cache. - setTimeout(() => { - try { - const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true }); - if (assetSignature(assets)) refreshRelatedThumbnails(assets, canonical, item.id).catch(e => log(String(e))); - } catch (e) { log('复合后缀补刷失败: ' + String(e)); } - }, 1500); } + 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; }; @@ -210,7 +204,6 @@ async function generate({ src, dest, item, deferRefresh }) { // 找不到完整 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); @@ -358,9 +351,9 @@ async function createWebpCopy(sourcePath, key) { /** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */ async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) { const signature = assetSignature(assets); - if (!signature) return; + if (!signature) return false; const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))]; - if (ids.length === 0) return; + if (ids.length === 0) return true; const key = signature + '|' + ids.slice().sort().join(','); if (refreshJobs.has(key)) return refreshJobs.get(key); const job = (async () => { @@ -374,8 +367,10 @@ async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) { 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 */ } @@ -384,7 +379,7 @@ async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) { })(); refreshJobs.set(key, job); try { - await job; + return await job; } finally { setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000); } @@ -395,56 +390,32 @@ async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) { * 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。 */ 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, - 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 (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); }); + 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;