/** * 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; } /** * 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/.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, options) { options = options || {}; const strictStem = !!options.strictStem; const preferCompatibleJson = !!options.preferCompatibleJson; 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 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入 // folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。 const validFolders = getValidFolders(filePath); 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)); 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 逻辑文件夹及素材时间。 // 这可区分同一文件夹中多次导入的同名 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) => 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); } } // 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; } } // 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 > 30000) { 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 目录里的同名贴图(最可靠) 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)); 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; } } 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)) { 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, importTimeOf, versionFamily, atlasFamily, itemMeta, isItemDeleted, };