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