优化识别

This commit is contained in:
tianmo committed 2026-09-09 00:27:25 +08:00
1 parent 7263a841cb
commit 5cb2e76471
9 files changed
+302 -66

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
const fs = require('fs');
const path = require('path');
const badgePath = path.join(__dirname, '..', 'assets', 'spine-badge.png');
let cachedDataUrl = null;
function dataUrl() {
if (!cachedDataUrl) {
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgePath).toString('base64');
}
return cachedDataUrl;
}
module.exports = { path: badgePath, dataUrl };
+19 -1
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const vm = require('vm');
const path = require('path');
const utils = require('./spine-utils.js');
const spineBadge = require('./spine-badge.js');
let canvasLib = null;
try {
@@ -15,6 +16,22 @@ try {
canvasLib = null;
}
let spineBadgePromise = null;
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
async function drawSpineBadge(ctx, width, height) {
if (!canvasLib || !ctx) return;
try {
if (!spineBadgePromise) {
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
}
const badge = await spineBadgePromise;
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
}
/**
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
@@ -188,8 +205,9 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
ctx.restore();
}
await drawSpineBadge(ctx, cw, ch);
const buf = canvas.toBuffer('image/png');
return buf;
}
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, canvasLib };
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
+95 -25
View File
@@ -142,6 +142,21 @@ function btimeOf(fp) {
return (d && d.btime) || 0;
}
/**
* Eagle item 的实际入库时间。
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
*/
function importTimeOf(fp) {
try {
const metaPath = path.join(path.dirname(fp), 'metadata.json');
const stat = fs.statSync(metaPath);
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
} catch (e) {
return btimeOf(fp);
}
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
@@ -343,7 +358,10 @@ function resolveRealSource(filePath) {
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath) {
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const preferCompatibleJson = !!options.preferCompatibleJson;
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
@@ -353,9 +371,11 @@ function discoverAssets(filePath) {
// 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。
buildRootsWithLibraryRoot(filePath, roots);
// 只匹配选中文件所在的 Eagle 文件夹(及子文件夹)内的资源,避免串到其他项目
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
const files = collectSpineFiles(roots, validFolders);
let files = collectSpineFiles(roots, validFolders);
if (strictStem) files = collectSpineFiles(roots, null);
const skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
@@ -367,22 +387,29 @@ function discoverAssets(filePath) {
return true;
});
// 工具:优先选择与 refFile 位于同一个 Eagle 逻辑文件夹的候选;
// 同文件夹内有多个时,再按“创建时间 btime 最接近”选取(视为同一批导入)。
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refImportTime = importTimeOf(refFile);
const refBtime = btimeOf(refFile);
const scored = cands.map((c) => {
let score = 0;
const ranked = cands.map((c) => {
const cFolders = foldersOfItem(c.fullPath);
if (refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1)) score += 1e6;
// btime 越接近越好(差值作为负加成)
score += -Math.abs(btimeOf(c.fullPath) - refBtime);
return { c, score };
return {
c,
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
};
});
scored.sort((a, b) => b.score - a.score);
return scored[0].c;
ranked.sort((a, b) =>
a.importDelta - b.importDelta ||
b.sameFolder - a.sameFolder ||
a.btimeDelta - b.btimeDelta ||
a.c.fullPath.localeCompare(b.c.fullPath)
);
return ranked[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
@@ -398,7 +425,8 @@ function discoverAssets(filePath) {
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.name === selected) || null;
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
@@ -410,19 +438,43 @@ function discoverAssets(filePath) {
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
}
if (!skeleton && realSkeletonFiles.length > 0) {
// 忽略 stem:优先与选中 atlas 同家族,再按同 Eagle 文件夹 + 最近 btime 选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
if (strictStem) {
skeleton = null;
} else {
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = selectBest(cands, filePath);
}
}
// Spine 3.6/3.7 的官方 JS 运行时不能读取对应二进制格式。若同批导入中
// 同时带有 JSON,则 atlas 和 skel 条目都用该 JSON 渲染真实缩略图。
if (preferCompatibleJson && skeleton && isSkeletonFile(skeleton.name)) {
let selectedVersion = null;
try { selectedVersion = detectVersion(skeleton.fullPath); } catch (e) { /* ignore */ }
const isOldBinary = /\.skel(?:\.bytes)?$/i.test(skeleton.name) && selectedVersion && parseFloat(selectedVersion) < 3.8;
if (isOldBinary) {
const stem = normalizeStem(skeleton.name);
const jsonCands = realSkeletonFiles.filter((f) =>
f.name.toLowerCase().endsWith('.json') &&
normalizeStem(f.name) === stem &&
familyOfSkel(f) === familyOfSkel(skeleton) &&
(!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000)
);
const jsonFallback = selectBest(jsonCands, skeleton.fullPath);
if (jsonFallback) skeleton = jsonFallback;
}
skeleton = selectBest(cands, filePath);
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
if (isAtlasFile(selected)) {
atlas = atlasFiles.find((f) => f.name === selected) || null;
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
atlasFiles.find((f) => f.name === selected) || null;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
@@ -446,10 +498,24 @@ function discoverAssets(filePath) {
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
// 忽略 stem:用与骨骼同 Eagle 文件夹 + 同家族(3x/4x) + 最近 btime 的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
if (strictStem) {
atlas = null;
} else {
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
}
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > 30000) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
}
if (!atlas && atlasFiles.length === 1) atlas = atlasFiles[0];
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
@@ -468,7 +534,8 @@ function discoverAssets(filePath) {
// 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn));
const hit = selectBest(cands, atlas.fullPath);
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
}
}
@@ -484,7 +551,9 @@ function discoverAssets(filePath) {
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
};
}
@@ -535,6 +604,7 @@ module.exports = {
getValidFolders,
mtimeOf,
btimeOf,
importTimeOf,
versionFamily,
atlasFamily,
itemMeta,