1.1.0版本
This commit is contained in:
1 parent
5cb2e76471
commit
deaefa6f8f
2 files changed
+233
-15
No files matched your search
+185
-6
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user