846 lines
31 KiB
JavaScript
846 lines
31 KiB
JavaScript
/**
|
||
* 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);
|
||
const fileIndexCache = new Map();
|
||
const skeletonJsonCache = new Map();
|
||
const JSON_PROBE_BYTES = 256 * 1024;
|
||
|
||
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
|
||
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
|
||
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
|
||
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
|
||
const SUFFIX_MAX_LENGTH = 50;
|
||
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
|
||
let activeSuffixes = DEFAULT_SUFFIXES.slice();
|
||
let suffixRe = buildSuffixRe(activeSuffixes);
|
||
|
||
function escapeRegExp(text) {
|
||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
function buildSuffixRe(list) {
|
||
if (!list || list.length === 0) return null;
|
||
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
|
||
}
|
||
|
||
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
|
||
function normalizeSuffixName(raw) {
|
||
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
|
||
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
|
||
}
|
||
|
||
/**
|
||
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
|
||
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
|
||
*/
|
||
function setSuffixes(list) {
|
||
const custom = [];
|
||
for (const raw of Array.isArray(list) ? list : []) {
|
||
const name = normalizeSuffixName(raw);
|
||
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
|
||
if (custom.indexOf(name) === -1) custom.push(name);
|
||
}
|
||
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
|
||
suffixRe = buildSuffixRe(activeSuffixes);
|
||
return getSuffixes();
|
||
}
|
||
|
||
/** 当前生效的尾缀(内置 + 自定义) */
|
||
function getSuffixes() {
|
||
return activeSuffixes.slice();
|
||
}
|
||
|
||
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
|
||
function getCustomSuffixes() {
|
||
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||
}
|
||
|
||
function unsafeAtlasPath(name, reason) {
|
||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||
error.code = 'UNSAFE_ATLAS_PATH';
|
||
return error;
|
||
}
|
||
|
||
/**
|
||
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
|
||
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
|
||
*/
|
||
function validateAtlasPageName(name) {
|
||
const raw = String(name == null ? '' : name).trim();
|
||
if (!raw) throw unsafeAtlasPath(name, '路径为空');
|
||
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
|
||
const portable = raw.replace(/\\/g, '/');
|
||
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
|
||
throw unsafeAtlasPath(raw, '不允许绝对路径');
|
||
}
|
||
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
|
||
const parts = portable.split('/');
|
||
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
|
||
const normalized = path.posix.normalize(portable);
|
||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
||
throw unsafeAtlasPath(raw, '路径无效');
|
||
}
|
||
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
|
||
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
|
||
}
|
||
return normalized.replace(/^\.\//, '');
|
||
}
|
||
|
||
function isPathInside(basePath, targetPath) {
|
||
const relative = path.relative(basePath, targetPath);
|
||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
||
}
|
||
|
||
function realpath(filePath) {
|
||
const implementation = fs.realpathSync.native || fs.realpathSync;
|
||
return implementation(filePath);
|
||
}
|
||
|
||
/**
|
||
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
|
||
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
|
||
*/
|
||
function resolveFileWithin(baseDir, relativeName, atlasPage) {
|
||
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
|
||
const base = path.resolve(baseDir);
|
||
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
|
||
if (!isPathInside(base, candidate)) {
|
||
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
|
||
return null;
|
||
}
|
||
if (!fs.existsSync(candidate)) return null;
|
||
let realBase;
|
||
let realCandidate;
|
||
try {
|
||
realBase = realpath(base);
|
||
realCandidate = realpath(candidate);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
if (!isPathInside(realBase, realCandidate)) {
|
||
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
|
||
return null;
|
||
}
|
||
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
|
||
return candidate;
|
||
}
|
||
|
||
function resolveAtlasTextureFile(atlasDir, pageName) {
|
||
return resolveFileWithin(atlasDir, pageName, true);
|
||
}
|
||
|
||
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,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
|
||
function normalizeStem(name) {
|
||
const lower = stemOf(name).toLowerCase();
|
||
return suffixRe ? lower.replace(suffixRe, '') : lower;
|
||
}
|
||
|
||
/** 从 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.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
|
||
*/
|
||
function chooseRuntimeDir(version, isBinary) {
|
||
if (!version) return null;
|
||
const major = parseInt(version.split('.')[0], 10);
|
||
if (major === 3) {
|
||
const minor = parseFloat(version);
|
||
if (minor === 3.4) return '3.4';
|
||
if (minor === 3.5) return '3.5';
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
|
||
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
|
||
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
|
||
*/
|
||
function classifySkeletonJson(filePath) {
|
||
try {
|
||
const stat = fs.statSync(filePath);
|
||
if (!stat.isFile() || stat.size === 0) return 'pending';
|
||
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
|
||
const cached = skeletonJsonCache.get(filePath);
|
||
if (cached && cached.signature === signature) return cached.kind;
|
||
|
||
const length = Math.min(stat.size, JSON_PROBE_BYTES);
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const buffer = Buffer.allocUnsafe(length);
|
||
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
|
||
const text = buffer.toString('utf8');
|
||
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
|
||
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
|
||
let kind;
|
||
if (versionMatch) {
|
||
kind = 'spine';
|
||
} else if (stat.size > JSON_PROBE_BYTES) {
|
||
kind = 'other';
|
||
} else {
|
||
try {
|
||
JSON.parse(text);
|
||
kind = 'other';
|
||
} catch (e) {
|
||
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
|
||
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
|
||
kind = hasSkeletonKey ? 'pending' : 'other';
|
||
}
|
||
}
|
||
|
||
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
|
||
skeletonJsonCache.set(filePath, { signature, kind });
|
||
return kind;
|
||
} catch (e) {
|
||
return 'pending';
|
||
}
|
||
}
|
||
|
||
function looksLikeSkeletonJson(filePath) {
|
||
return classifySkeletonJson(filePath) === 'spine';
|
||
}
|
||
|
||
/** 文件修改时间(毫秒) */
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
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) {
|
||
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
|
||
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
|
||
const probe = path.join(root, '__spineviewer_probe__');
|
||
const libRoot = findLibraryRoot(probe);
|
||
if (libRoot) {
|
||
try {
|
||
const imagesReal = realpath(path.join(libRoot, 'images'));
|
||
const rootReal = realpath(root);
|
||
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
|
||
} catch (e) { continue; }
|
||
}
|
||
let entries;
|
||
try { entries = fs.readdirSync(root); } catch (e) { continue; }
|
||
for (const name of entries) {
|
||
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
|
||
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
|
||
const full = resolveFileWithin(root, name, false);
|
||
if (!full) continue;
|
||
// 排除 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;
|
||
}
|
||
|
||
/**
|
||
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
|
||
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
|
||
* 正在落盘的新文件。
|
||
*/
|
||
function collectSpineFilesCached(filePath, roots, refresh) {
|
||
const libRoot = findLibraryRoot(filePath);
|
||
// Outside an Eagle library, sibling asset folders are independent search
|
||
// scopes. Sharing their parent as a cache key can return another skeleton.
|
||
const key = libRoot || JSON.stringify([...roots].sort());
|
||
const now = Date.now();
|
||
const cached = fileIndexCache.get(key);
|
||
if (!refresh && cached && now - cached.time < 1500) return cached.files;
|
||
const files = collectSpineFiles(roots, null);
|
||
fileIndexCache.set(key, { time: now, files });
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* 修正不可靠的源路径。
|
||
* 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, options) {
|
||
options = options || {};
|
||
const strictStem = !!options.strictStem;
|
||
const requestedCrossBatch = !!options.allowCrossBatch;
|
||
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
|
||
filePath = resolveRealSource(filePath);
|
||
const selected = path.basename(filePath);
|
||
const selectedStem = normalizeStem(selected);
|
||
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
|
||
let roots;
|
||
if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
|
||
roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
|
||
} else {
|
||
roots = getSearchRoots(filePath);
|
||
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
|
||
// 明确列举的候选 item 目录,避免每次预览扫描整库。
|
||
buildRootsWithLibraryRoot(filePath, roots);
|
||
}
|
||
|
||
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
|
||
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
|
||
const validFolders = getValidFolders(filePath);
|
||
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
|
||
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
|
||
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
|
||
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
|
||
let files = collectSpineFiles(roots, validFolders);
|
||
if (strictStem) {
|
||
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
|
||
if (allowCrossBatch && validFolders && validFolders.size) {
|
||
files = allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)));
|
||
} else {
|
||
files = allFiles;
|
||
}
|
||
}
|
||
|
||
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
|
||
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
|
||
const textureFiles = files.filter((f) => isTextureFile(f.name));
|
||
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
|
||
// 避免每个回调都解析库中所有普通 JSON。
|
||
if (strictStem) {
|
||
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
|
||
}
|
||
|
||
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
|
||
const realSkeletonFiles = skeletonFiles.filter((f) => {
|
||
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
|
||
return true;
|
||
});
|
||
|
||
// 工具:优先选择与 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 ranked = cands.map((c) => {
|
||
const cFolders = foldersOfItem(c.fullPath);
|
||
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,
|
||
};
|
||
});
|
||
ranked.sort((a, b) => allowCrossBatch
|
||
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
|
||
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
|
||
);
|
||
return ranked[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.fullPath === filePath) ||
|
||
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) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
|
||
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
|
||
let atlas = null;
|
||
if (isAtlasFile(selected)) {
|
||
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
|
||
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) {
|
||
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 > allowedImportDelta) {
|
||
if (isAtlasFile(selected)) skeleton = null;
|
||
else atlas = null;
|
||
}
|
||
}
|
||
|
||
// 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 目录内的安全相对路径;realpath 必须仍在目录内。
|
||
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
|
||
if (inAtlasDir) {
|
||
texturePaths[pn] = inAtlasDir;
|
||
continue;
|
||
}
|
||
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
|
||
const pageFileName = path.posix.basename(pn);
|
||
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
|
||
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
|
||
let hit = selectBest(cands, atlas.fullPath);
|
||
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
|
||
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),
|
||
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
|
||
atlasCandidates: atlasFiles.map((f) => f.name),
|
||
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
|
||
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) && !/[=,:]/.test(line)) {
|
||
pages.push(validateAtlasPageName(line));
|
||
} else if (line === '') {
|
||
// 空白行之后的下一个非空行可能是下一页文件名
|
||
continue;
|
||
}
|
||
}
|
||
return [...new Set(pages)];
|
||
}
|
||
|
||
/**
|
||
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
|
||
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
|
||
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
|
||
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
|
||
*/
|
||
function parseAtlasPma(atlasText) {
|
||
if (typeof atlasText !== 'string') return undefined;
|
||
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
|
||
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
|
||
}
|
||
|
||
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
|
||
function preferredSkin(skeletonData) {
|
||
if (!skeletonData) return null;
|
||
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
|
||
const skins = skeletonData.skins || [];
|
||
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
|
||
}
|
||
|
||
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
|
||
function applySkin(skeleton, skin) {
|
||
if (!skeleton || !skin) return false;
|
||
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
|
||
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
|
||
else return false;
|
||
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
|
||
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
|
||
return true;
|
||
}
|
||
|
||
module.exports = {
|
||
SKELETON_EXTS,
|
||
ATLAS_EXTS,
|
||
TEXTURE_EXTS,
|
||
ALL_EXTS,
|
||
extOf,
|
||
isSkeletonFile,
|
||
isAtlasFile,
|
||
isTextureFile,
|
||
stemOf,
|
||
normalizeStem,
|
||
DEFAULT_SUFFIXES,
|
||
SUFFIX_MAX_LENGTH,
|
||
normalizeSuffixName,
|
||
setSuffixes,
|
||
getSuffixes,
|
||
getCustomSuffixes,
|
||
detectVersion,
|
||
detectVersionFromJson,
|
||
detectVersionFromBuffer,
|
||
chooseRuntimeDir,
|
||
classifySkeletonJson,
|
||
looksLikeSkeletonJson,
|
||
discoverAssets,
|
||
parseAtlasPages,
|
||
validateAtlasPageName,
|
||
resolveAtlasTextureFile,
|
||
parseAtlasPma,
|
||
preferredSkin,
|
||
applySkin,
|
||
isEagleItemDir,
|
||
getSearchRoots,
|
||
collectSpineFiles,
|
||
foldersOfItem,
|
||
findLibraryRoot,
|
||
readFolderInfo,
|
||
getValidFolders,
|
||
mtimeOf,
|
||
btimeOf,
|
||
importTimeOf,
|
||
versionFamily,
|
||
atlasFamily,
|
||
itemMeta,
|
||
isItemDeleted,
|
||
};
|