优化识别
This commit is contained in:
1 parent
7263a841cb
commit
5cb2e76471
9 files changed
+302
-66
No files matched your search
@@ -20,8 +20,10 @@
|
||||
SpineViewer/
|
||||
├─ manifest.json # 插件清单(预览扩展注册)
|
||||
├─ logo.png # 插件图标
|
||||
├─ assets/
|
||||
│ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge
|
||||
├─ thumbnail/
|
||||
│ └─ spine.js # 缩略图生成(渲染骨骼首帧为 PNG)
|
||||
│ └─ spine.js # 缩略图生成(渲染骨骼首帧并交由 Eagle 缓存)
|
||||
├─ viewer/
|
||||
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
|
||||
├─ js/
|
||||
@@ -57,7 +59,7 @@ SpineViewer/
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
|
||||
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。
|
||||
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,便于识别数据文件。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +73,7 @@ SpineViewer/
|
||||
## 关于新版/旧版二进制
|
||||
|
||||
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
|
||||
- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时加载。插件会检测到这种情况并给出提示(缩略图显示占位图),建议改用 **JSON** 导出即可正常预览。
|
||||
- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时直接加载。同批存在 **JSON** 时,插件会用 JSON 为 `atlas/json/skel` 三个条目生成相同的骨骼缩略图;没有 JSON 时则显示实际 atlas 贴图预览。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Spine badge
|
||||
|
||||
`spine-badge.png` 下载自 Esoteric Software 官方 [Spine Brand assets](https://esotericsoftware.com/branding) 页面。
|
||||
|
||||
插件只在缩略图右下角等比缩放显示该 badge,用于标明条目是 Spine 数据文件;图形本身未做拉伸、变色或其他修改。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "f6602400-1327-485c-98e0-78ae9fded460",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"platform": "all",
|
||||
"arch": "all",
|
||||
"name": "SpineViewer",
|
||||
@@ -25,4 +25,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
-35
@@ -15,33 +15,50 @@ function log(msg) {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||
const VERSION = 'v4';
|
||||
const VERSION = 'v6';
|
||||
|
||||
/** 发现配套资源;带重试,以应对 Eagle 导入时“多文件分批写入库”的时序问题。
|
||||
* 同伴文件(骨骼/图集)可能在 Atlas 的缩略图生成后才落盘,故需等待。
|
||||
* 一旦发现即立即返回(不会傻等满窗口),窗口仅作为最坏情况的上限。
|
||||
* 采用 10 次 × 150ms ≈ 1.5 秒;间隔更密、最快感知同伴落盘。 */
|
||||
async function discoverWithRetry(src, retries = 10, delay = 150) {
|
||||
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
|
||||
function assetSignature(assets) {
|
||||
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
|
||||
let atlasText = '';
|
||||
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
|
||||
const pages = utils.parseAtlasPages(atlasText);
|
||||
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
|
||||
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
|
||||
const stats = [];
|
||||
for (const file of paths) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile() || stat.size === 0) return null;
|
||||
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return stats.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
|
||||
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
|
||||
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
|
||||
*/
|
||||
async function discoverWithRetry(src, retries = 48, delay = 250) {
|
||||
let assets = null;
|
||||
let previousSignature = null;
|
||||
let stableCount = 0;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
assets = utils.discoverAssets(src);
|
||||
if (assets.skeletonPath && assets.atlasPath) return assets;
|
||||
assets = utils.discoverAssets(src, { strictStem: true, preferCompatibleJson: true });
|
||||
const signature = assetSignature(assets);
|
||||
if (signature && signature === previousSignature) stableCount += 1;
|
||||
else stableCount = signature ? 1 : 0;
|
||||
previousSignature = signature;
|
||||
if (stableCount >= 3) return assets;
|
||||
await sleep(delay);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/** 渲染缩略图;失败时重试一次(应对偶发渲染错误) */
|
||||
async function renderWithRetry(opts) {
|
||||
try {
|
||||
return await render.renderThumbnail(opts);
|
||||
} catch (e) {
|
||||
log('renderThumbnail 首次失败: ' + (e && e.message) + ',重试...');
|
||||
await sleep(150);
|
||||
return await render.renderThumbnail(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 格式扩展缩略图处理函数。
|
||||
* 参数:{ src, dest, item }
|
||||
@@ -73,34 +90,77 @@ module.exports = async ({ src, dest, item }) => {
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
const assets = await discoverWithRetry(src);
|
||||
let assets = await discoverWithRetry(src);
|
||||
|
||||
// 找不到配套 Spine 资源
|
||||
if (!assets.skeletonPath || !assets.atlasPath) {
|
||||
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
|
||||
await writePlaceholder(dest, path.basename(src, utils.extOf(path.basename(src)) || ''));
|
||||
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
const version = utils.detectVersion(assets.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
let version = utils.detectVersion(assets.skeletonPath);
|
||||
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
|
||||
// 3.6/3.7 二进制没有对应解析器。若有同批 JSON,资源发现阶段已自动切换;
|
||||
// 走到这里说明没有 JSON,直接显示 atlas 实际贴图,避免无意义地重复解析。
|
||||
if (assets.isBinary && version && parseFloat(version) < 3.8) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('旧版二进制无 JSON,已使用 atlas 贴图缩略图: ' + src);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!runtimeDir) {
|
||||
await writePlaceholder(dest, path.basename(assets.skeletonPath, path.extname(assets.skeletonPath)));
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// 渲染首帧到 PNG
|
||||
const png = await renderWithRetry({
|
||||
skeletonPath: assets.skeletonPath,
|
||||
atlasPath: assets.atlasPath,
|
||||
texturePaths: assets.texturePaths,
|
||||
version,
|
||||
runtimeDir,
|
||||
size: 400,
|
||||
});
|
||||
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
|
||||
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
|
||||
let png = null;
|
||||
let lastRenderError = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
png = await render.renderThumbnail({
|
||||
skeletonPath: assets.skeletonPath,
|
||||
atlasPath: assets.atlasPath,
|
||||
texturePaths: assets.texturePaths,
|
||||
version,
|
||||
runtimeDir,
|
||||
size: 400,
|
||||
});
|
||||
break;
|
||||
} catch (e) {
|
||||
lastRenderError = e;
|
||||
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
|
||||
if (attempt === 2) break;
|
||||
await sleep(500);
|
||||
assets = await discoverWithRetry(src, 16, 250);
|
||||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
|
||||
version = utils.detectVersion(assets.skeletonPath);
|
||||
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
}
|
||||
}
|
||||
if (!png) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
throw lastRenderError || new Error('Spine 资源尚未写入完成');
|
||||
}
|
||||
|
||||
fs.writeFileSync(dest, png);
|
||||
|
||||
@@ -115,7 +175,7 @@ module.exports = async ({ src, dest, item }) => {
|
||||
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
|
||||
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
|
||||
try {
|
||||
await writePlaceholder(dest, 'Spine');
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400;
|
||||
item.height = 400;
|
||||
return resolve(item);
|
||||
@@ -153,6 +213,33 @@ async function copyImageAsThumb(src, dest) {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
|
||||
async function writeAtlasFallback(assets, dest) {
|
||||
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
|
||||
const texturePath = Object.values(assets.texturePaths).find((file) => {
|
||||
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
|
||||
});
|
||||
if (!texturePath) return false;
|
||||
try {
|
||||
const img = await render.canvasLib.loadImage(texturePath);
|
||||
const size = 400;
|
||||
const canvas = render.canvasLib.createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
|
||||
await render.drawSpineBadge(ctx, size, size);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('atlas 贴图回退失败: ' + (e && e.message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 PNG 宽高(仅支持 PNG 头部) */
|
||||
function imageSizeOf(file) {
|
||||
const buf = fs.readFileSync(file);
|
||||
@@ -163,8 +250,19 @@ function imageSizeOf(file) {
|
||||
}
|
||||
|
||||
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
|
||||
async function writePlaceholder(dest, text) {
|
||||
async function writePlaceholder(dest) {
|
||||
const w = 400, h = 400;
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const canvas = render.canvasLib.createCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
await render.drawSpineBadge(ctx, w, h);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through to pure JS encoder */ }
|
||||
}
|
||||
// 深色底
|
||||
const pixels = Buffer.alloc(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
|
||||
@@ -171,6 +171,34 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const utils = require('../js/spine-utils.js');
|
||||
const spineBadge = require('../js/spine-badge.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
if (spineBadgeImagePromise) return spineBadgeImagePromise;
|
||||
spineBadgeImagePromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Spine badge 加载失败'));
|
||||
image.src = spineBadge.dataUrl();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
return spineBadgeImagePromise;
|
||||
}
|
||||
|
||||
async function drawSpineBadge(targetCtx, width, height) {
|
||||
try {
|
||||
const badge = await loadSpineBadgeImage();
|
||||
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
targetCtx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (e) {
|
||||
log('绘制 Spine badge 失败: ' + ((e && e.message) || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
|
||||
function log(msg) {
|
||||
@@ -429,6 +457,7 @@
|
||||
offRenderer.draw(skeleton);
|
||||
octx.restore();
|
||||
}
|
||||
await drawSpineBadge(octx, size, size);
|
||||
|
||||
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
|
||||
Reference in new issue
Block a user