eagle预览spine插件

This commit is contained in:
tianmo committed 2026-09-08 18:42:08 +08:00
1 parent 3d72e88d88
commit 91815a355b
27 files changed
+70424

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
const fs = require('fs');
var OSTYPE = {
ICON: '32×32',
'ICN#': '32×32',
'icm#': '16×12',
icm4: '16×12',
icm8: '16×12 ',
'ics#': '16×16',
ics4: '16×16',
ics8: '16×16',
is32: '16×16',
s8mk: '16×16',
icl4: '32×32',
icl8: '32×32',
il32: '32×32',
l8mk: '32×32',
'ich#': '48×48',
ich4: '48×48',
ich8: '48×48',
ih32: '48×48',
h8mk: '48×48',
it32: '128×128',
t8mk: '128×128',
icp4: '16x16',
icp5: '32x32',
icp6: '64x64',
ic07: '128x128',
ic08: '256×256',
ic09: '512×512',
ic10: '1024×1024',
ic11: '16x16@2x',
ic12: '32x32@2x',
ic13: '128x128@2x',
ic14: '256x256@2x ',
};
var Iconutil = (function () {
function Iconutil(buffer) {
var header = this.fetchHeader(buffer);
var pointer = header.data.length;
var images = [];
while (pointer < header.bytes) {
var _a = this.fetchImages(buffer.slice(pointer)), image = _a.image, data = _a.data, osType_1 = _a.osType;
Buffer.isBuffer(image) &&
images.push({
size: OSTYPE[osType_1],
image: image,
});
pointer += data.length;
}
this.images = images;
}
Iconutil.prototype.fetchHeader = function (buffer) {
var id = buffer.toString('ascii', 0, 4);
var bytes = buffer.readUInt32BE(4);
var data = Buffer.alloc(8);
data.write(id, 0, 4, 'ascii');
data.writeUInt32BE(bytes, 4);
return {
bytes: bytes,
data: data,
};
};
Iconutil.prototype.fetchImages = function (buffer) {
var osType = buffer.toString('ascii', 0, 4);
var bytes = buffer.readUInt32BE(4);
var image = buffer.slice(8, bytes);
var _buffer = Buffer.alloc(8);
_buffer.write(osType, 0, 4, 'ascii');
_buffer.writeUInt32BE(bytes, 4);
var data = Buffer.concat([_buffer, image]);
return {
data: data,
image: image,
osType: osType,
};
};
return Iconutil;
}());
async function icns2png (src, dist) {
return new Promise(async (resolve, reject) => {
try {
let buffer = await fs.promises.readFile(src);
let images = new Iconutil(buffer).images;
let max = 0;
let maxImage;
images.forEach(function (_a) {
if (_a && _a.image && _a.image.length > max) {
max = _a.image.length;
maxImage = _a.image;
}
});
if (maxImage) {
await fs.promises.writeFile(dist, maxImage);
return resolve();
}
return reject(new Error(`Can not parse the icns file.`));
}
catch (err) {
return reject(err);
}
});
}
async function icns2buffer (src) {
return new Promise(async (resolve, reject) => {
try {
let buffer = await fs.promises.readFile(src);
let images = new Iconutil(buffer).images;
let max = 0;
let maxImage;
images.forEach(function (_a) {
if (_a && _a.image && _a.image.length > max) {
max = _a.image.length;
maxImage = _a.image;
}
});
if (maxImage) {
return resolve(maxImage);
}
return reject(new Error(`Can not parse the icns file.`));
}
catch (err) {
return reject(err);
}
});
}
module.exports = {
icns2png: icns2png,
icns2buffer: icns2buffer
};
+18
View File
@@ -0,0 +1,18 @@
module.exports = async (filePath) => {
return new Promise((resolve, reject) => {
var img = new Image();
img.onload = function () {
resolve({
width: img.width,
height: img.height,
})
};
img.onerror = function (event) {
resolve({
width: 0,
height: 0,
})
};
img.src = require("url").pathToFileURL(filePath).href;
});
}
+195
View File
@@ -0,0 +1,195 @@
/**
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
*/
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const utils = require('./spine-utils.js');
let canvasLib = null;
try {
canvasLib = require('@napi-rs/canvas');
} catch (e) {
canvasLib = null;
}
/**
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
*/
function loadRuntime(dir) {
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
performance: { now: () => Date.now() },
};
// 部分运行时通过 window/self/globalThis 访问全局
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
return sandbox.spine;
}
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
// 解析所有页面贴图名,并预先异步加载
let pageNames = [];
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
} catch (e) { /* ignore */ }
}));
const makeTexture = (name) => {
let img = textureMap[name];
if (!img) {
// 找不到图片时用 2x2 灰色占位纹理
const c = canvasLib.createCanvas(2, 2);
const cx = c.getContext('2d');
cx.fillStyle = '#888888';
cx.fillRect(0, 0, 2, 2);
img = c;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
/** 读取骨骼数据(json 或二进制) */
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buf = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buf));
} else {
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
const text = fs.readFileSync(skeletonPath, 'utf8');
return loader.readSkeletonData(text);
}
}
/** 计算包围盒(跨版本兼容) */
function getBounds(spine, skeleton) {
try {
if (skeleton.getBoundsRect) {
const r = skeleton.getBoundsRect();
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (skeleton.getBounds) {
const offset = new spine.Vector2();
const size = new spine.Vector2();
skeleton.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
/**
* 渲染首帧动画到 PNG buffer。
* @returns {Promise<Buffer>}
*/
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
// 3.6/3.7 的二进制骨骼与 3.8 运行时二进制格式不兼容,
// 且 3.6/3.7 的 JS 运行时本身不含二进制解析器,因此这类文件无法加载。
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
const name = path.basename(skeletonPath);
throw new Error(version && parseFloat(version) < 3.8
? 'Spine ' + version + ' 的二进制骨骼(' + name + ')无法由官方 JS 运行时解析,请改用 .json 格式导出。'
: '骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
let bounds = null;
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
if (skeleton.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
bounds = getBounds(spine, skeleton);
const cw = size, ch = size;
const canvas = canvasLib.createCanvas(cw, ch);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, cw, ch);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.scale(scale, -scale);
ctx.translate(-cx, -cy);
let renderer = null;
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
renderer.draw(skeleton);
}
ctx.restore();
}
const buf = canvas.toBuffer('image/png');
return buf;
}
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, canvasLib };
+542
View File
@@ -0,0 +1,542 @@
/**
* SpineViewer - 工具模块
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
* 职责:
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
* - 从骨骼数据中检测 Spine 版本
* - 根据版本选择合适的本地运行时
*/
const fs = require('fs');
const path = require('path');
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
// Spine 导出常见的后缀,匹配时归一化掉
const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i;
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
}
for (let i = 0; i < ATLAS_EXTS.length; i++) {
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
}
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
}
return null;
}
function isSkeletonFile(name) {
const ext = extOf(name);
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
}
function isAtlasFile(name) {
const ext = extOf(name);
return ext === '.atlas' || ext === '.atlas.txt';
}
function isTextureFile(name) {
const ext = extOf(name);
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
if (!ext) return name;
return name.slice(0, name.length - ext.length);
}
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */
function normalizeStem(name) {
return stemOf(name).toLowerCase().replace(SUFFIX_RE, '');
}
/** 从 JSON 文本中读取 spine 版本 */
function detectVersionFromJson(text) {
try {
const json = JSON.parse(text);
if (json && json.skeleton && json.skeleton.spine) {
return String(json.skeleton.spine);
}
} catch (e) { /* ignore */ }
return null;
}
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
function detectVersionFromBuffer(buf) {
let s = '';
const bytes = new Uint8Array(buf);
const len = Math.min(bytes.length, 160);
for (let i = 0; i < len; i++) {
s += String.fromCharCode(bytes[i]);
}
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
}
/** 从文件读取 spine 版本 */
function detectVersion(filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith('.json')) {
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
}
return detectVersionFromBuffer(fs.readFileSync(filePath));
}
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.x 只有 3.8 的 JS 构建包含二进制解析器,因此二进制使用 3.8 运行时
*/
function chooseRuntimeDir(version, isBinary) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
if (isBinary) return '3.8';
const minor = parseFloat(version);
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
} else if (major === 4) {
const minor = parseFloat(version);
if (minor <= 4.0 + 0.0001) return '4.0';
if (minor <= 4.1 + 0.0001) return '4.1';
if (minor <= 4.2 + 0.0001) return '4.2';
return '4.3';
}
return null;
}
/**
* 判断给定的 JSON 文件是否为 Spine 骨骼 JSON,避免把普通 JSON 误判为骨骼数据。
* 判定条件:存在 `skeleton` 及其 `spine` 版本字段,或同时存在 `bones` + `slots` 数组。
*/
function looksLikeSkeletonJson(filePath) {
try {
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!json || typeof json !== 'object') return false;
if (json.skeleton && json.skeleton.spine) return true;
if (Array.isArray(json.bones) && json.bones.length > 0 && Array.isArray(json.slots)) return true;
return false;
} catch (e) {
return false;
}
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
}
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
function btimeOf(fp) {
const d = itemMeta(path.dirname(fp));
return (d && d.btime) || 0;
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return major <= 3 ? '3x' : '4x';
}
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
function atlasFamily(atlasText) {
if (!atlasText) return null;
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
if (/\bbounds\s*:/.test(atlasText)) return '4x';
if (/\bxy\s*:/.test(atlasText)) return '3x';
return '3x';
}
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
function itemMeta(itemDir) {
if (!itemDir) return null;
try {
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
} catch (e) {
return null;
}
}
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
function foldersOfItem(filePath) {
const d = itemMeta(path.dirname(filePath));
return d && Array.isArray(d.folders) ? d.folders : [];
}
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
function isItemDeleted(filePath) {
const d = itemMeta(path.dirname(filePath));
return !!(d && d.isDeleted);
}
/** 从任意 item 路径向上定位 Eagle 库根目录 */
function findLibraryRoot(filePath) {
let dir = path.dirname(filePath); // item 文件夹
for (let i = 0; i < 8; i++) {
const parent = path.dirname(dir);
if (parent === dir) break;
try {
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
return parent;
}
} catch (e) { /* ignore */ }
dir = parent;
}
return null;
}
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
function readFolderInfo(libRoot) {
const map = {};
const names = {};
if (!libRoot) return { map, names };
try {
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
const folders = d.folders || [];
for (const f of folders) {
if (!f) continue;
map[f.id] = Array.isArray(f.children) ? f.children : [];
names[f.id] = f.name || '';
}
} catch (e) { /* ignore */ }
return { map, names };
}
/**
* 计算允许匹配的 Eagle 文件夹 id 集合:
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
*/
function getValidFolders(filePath) {
const itemFolders = foldersOfItem(filePath);
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
return new Set(itemFolders);
}
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
function isEagleItemDir(dir) {
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
}
/**
* 计算需要搜索的目录列表。
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
*/
function getSearchRoots(filePath) {
const roots = [];
const itemDir = path.dirname(filePath);
roots.push(itemDir);
if (isEagleItemDir(itemDir)) {
const parent = path.dirname(itemDir);
try {
for (const name of fs.readdirSync(parent)) {
const sub = path.join(parent, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
return [...new Set(roots)];
}
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
function buildRootsWithLibraryRoot(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return;
for (const name of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
if (roots.indexOf(sub) === -1) roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
/**
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
*/
function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
const full = path.join(root, name);
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
const itemFolders = foldersOfItem(full);
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
}
if (seen.has(full)) continue;
seen.add(full);
out.push({ name, fullPath: full });
}
}
return out;
}
/**
* 修正不可靠的源路径。
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
* 源文件,作为后续资源发现的基准。
*/
function resolveRealSource(filePath) {
// 源目录存在且是有效 item 目录 → 直接用
const dir = path.dirname(filePath);
if (isEagleItemDir(dir)) return filePath;
const name = path.basename(filePath);
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return filePath;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return filePath;
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
let fallback = null;
for (const d of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, d);
if (!isEagleItemDir(sub)) continue;
const cand = path.join(sub, name);
if (fs.existsSync(cand)) {
// 优先选择未被删除的项
if (!isItemDeleted(cand)) return cand;
if (!fallback) fallback = cand;
}
}
if (fallback) return fallback;
} catch (e) { /* ignore */ }
return filePath;
}
/**
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath) {
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
const roots = getSearchRoots(filePath);
// 若同级范围无法凑齐骨架+图集(常见于 Eagle 导入瞬间,同伴落在别的 .info),
// 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。
buildRootsWithLibraryRoot(filePath, roots);
// 只匹配选中文件所在的 Eagle 文件夹(及子文件夹)内的资源,避免串到其他项目
const validFolders = getValidFolders(filePath);
const files = collectSpineFiles(roots, validFolders);
const skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
return true;
});
// 工具:优先选择与 refFile 位于同一个 Eagle 逻辑文件夹的候选;
// 同文件夹内有多个时,再按“创建时间 btime 最接近”选取(视为同一批导入)。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refBtime = btimeOf(refFile);
const scored = cands.map((c) => {
let score = 0;
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 };
});
scored.sort((a, b) => b.score - a.score);
return scored[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
const selectedAtlasFamily = isAtlasFile(selected)
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
: null;
// 1) 骨骼文件
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
if (selectedAtlasFamily && cands.length > 1) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
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;
}
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;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
const pickAtlas = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
if (skelFamily) {
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
if (same.length > 0) cands = same;
}
return selectBest(cands, refFile);
};
if (!atlas) {
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
atlas = pickAtlas(cands, filePath);
}
if (!atlas && skeleton) {
const skelStem = normalizeStem(skeleton.name);
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
// 忽略 stem:用与骨骼同 Eagle 文件夹 + 同家族(3x/4x) + 最近 btime 的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
if (!atlas && atlasFiles.length === 1) atlas = atlasFiles[0];
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
if (atlas) {
let text = '';
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
const inAtlasDir = path.join(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 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);
if (hit) texturePaths[pn] = hit.fullPath;
}
}
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
texturePaths,
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
atlasCandidates: atlasFiles.map((f) => f.name),
textureCandidates: textureFiles.map((f) => f.name),
};
}
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
function parseAtlasPages(atlasText) {
const pages = [];
if (!atlasText) return pages;
const lines = atlasText.split(/\r?\n/);
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
pages.push(line);
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
return pages.filter((p) => !/[=,:]/.test(p));
}
module.exports = {
SKELETON_EXTS,
ATLAS_EXTS,
TEXTURE_EXTS,
ALL_EXTS,
extOf,
isSkeletonFile,
isAtlasFile,
isTextureFile,
stemOf,
normalizeStem,
detectVersion,
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
foldersOfItem,
findLibraryRoot,
readFolderInfo,
getValidFolders,
mtimeOf,
btimeOf,
versionFamily,
atlasFamily,
itemMeta,
isItemDeleted,
};