1.1.1版本审核修复
[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。 跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。 [spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。 自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。 已删除后台整组补刷进程及 relatedDataPaths。 [viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。 当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。 手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。 八种语言文案及 README 已同步。 处理历史日志遗留问题
This commit is contained in:
1 parent
473a270510
commit
bb73cc9227
36 files changed
+499
-652
No files matched your search
+8
-135
@@ -1,16 +1,17 @@
|
||||
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');
|
||||
|
||||
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
|
||||
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
|
||||
function log(msg) {
|
||||
try {
|
||||
const logPath = path.join(__dirname, '..', 'thumbnails.log');
|
||||
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
|
||||
const logDir = path.join(os.tmpdir(), 'SpineViewer');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, 'thumbnails.log');
|
||||
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
@@ -18,10 +19,9 @@ function log(msg) {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const discoveryJobs = new Map();
|
||||
const renderJobs = new Map();
|
||||
const refreshJobs = new Map();
|
||||
|
||||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||
const VERSION = 'v14';
|
||||
const VERSION = 'v15';
|
||||
|
||||
function coordinationKey(src) {
|
||||
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
|
||||
@@ -132,7 +132,7 @@ module.exports = async (options) => {
|
||||
// Supply that requested output as well as the corrected compound-name cache.
|
||||
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
|
||||
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
|
||||
const result = await generate({ ...options, deferRefresh: changed });
|
||||
const result = await generate(options);
|
||||
if (canonical !== dest && fs.existsSync(dest)) {
|
||||
if (render.canvasLib) {
|
||||
const image = await render.canvasLib.loadImage(dest);
|
||||
@@ -144,12 +144,10 @@ module.exports = async (options) => {
|
||||
if (changed && item.id) {
|
||||
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
async function generate({ src, dest, item, deferRefresh }) {
|
||||
async function generate({ src, dest, item }) {
|
||||
try {
|
||||
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
|
||||
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
|
||||
@@ -217,7 +215,6 @@ async function generate({ src, dest, item, deferRefresh }) {
|
||||
|
||||
if (!runtimeDir) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
@@ -255,7 +252,6 @@ async function generate({ src, dest, item, deferRefresh }) {
|
||||
if (!png) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
|
||||
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
@@ -263,7 +259,6 @@ async function generate({ src, dest, item, deferRefresh }) {
|
||||
}
|
||||
|
||||
fs.writeFileSync(dest, png);
|
||||
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||||
|
||||
try {
|
||||
const size = imageSizeOf(dest);
|
||||
@@ -293,128 +288,6 @@ function resolve(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
function itemIdFromAssetPath(filePath) {
|
||||
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
|
||||
if (expectedFilename) {
|
||||
// Eagle's foreground may still hold the old name after a type change.
|
||||
// Do not let setCustomThumbnail persist that stale name back to metadata.
|
||||
const info = await new Promise((resolveInfo, rejectInfo) => {
|
||||
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
|
||||
let body = ''; response.on('data', chunk => { body += chunk; });
|
||||
response.on('end', () => {
|
||||
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
|
||||
catch (error) { rejectInfo(error); }
|
||||
});
|
||||
});
|
||||
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
|
||||
});
|
||||
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
|
||||
}
|
||||
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.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
|
||||
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 false;
|
||||
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
|
||||
if (ids.length === 0) return true;
|
||||
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) => {
|
||||
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
|
||||
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
|
||||
}));
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
})();
|
||||
refreshJobs.set(key, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
|
||||
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
|
||||
*/
|
||||
function scheduleBackgroundReconcile(src) {
|
||||
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