This commit is contained in:
tianmo committed 2026-09-10 14:04:53 +08:00
1 parent 9635428f78
commit ef01cdd6ee
6 files changed
+172 -66

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ SpineViewer/
- JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
- Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。
---
+85
View File
@@ -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;
});
+3 -3
View File
@@ -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 {
+6 -1
View File
@@ -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 }); }
+44
View File
@@ -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; });
+33 -62
View File
@@ -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;