eagle预览spine插件
This commit is contained in:
1 parent
3d72e88d88
commit
91815a355b
27 files changed
+70424
No files matched your search
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Dependencies (需要重新安装;离线打包时需手动包含 node_modules)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temp test artifacts
|
||||||
|
/tmp/
|
||||||
|
*.png.d.ts
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# SpineViewer(Eagle 插件)
|
||||||
|
|
||||||
|
一个 **完全离线** 的 Eagle 格式扩展插件,用于在 Eagle 中直接预览由 Spine 导出的骨骼动画。
|
||||||
|
|
||||||
|
插件只接管「骨骼数据」和「纹理图集数据」类型的预览:
|
||||||
|
- 骨骼数据:`skel`、`skel.bytes`、`json`
|
||||||
|
- 纹理图集数据:`atlas`、`atlas.txt`
|
||||||
|
|
||||||
|
普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。
|
||||||
|
|
||||||
|
支持的 Spine 版本:**3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
|
||||||
|
|
||||||
|
> 关键特性:无需网络、不走 CDN。所有 Spine 官方运行时代码都已拷贝到插件本地 `lib/` 目录,不含 git 子模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
SpineViewer/
|
||||||
|
├─ manifest.json # 插件清单(预览扩展注册)
|
||||||
|
├─ logo.png # 插件图标
|
||||||
|
├─ thumbnail/
|
||||||
|
│ └─ spine.js # 缩略图生成(渲染骨骼首帧为 PNG)
|
||||||
|
├─ viewer/
|
||||||
|
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
|
||||||
|
├─ js/
|
||||||
|
│ ├─ spine-utils.js # 资源匹配、版本检测工具
|
||||||
|
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
|
||||||
|
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
|
||||||
|
│ ├─ 3.6/ spine-canvas.js
|
||||||
|
│ ├─ 3.7/ spine-canvas.js
|
||||||
|
│ ├─ 3.8/ spine-canvas.js
|
||||||
|
│ ├─ 4.0/ spine-canvas.js
|
||||||
|
│ ├─ 4.1/ spine-canvas.js
|
||||||
|
│ ├─ 4.2/ spine-canvas.js
|
||||||
|
│ └─ 4.3/ spine-canvas.js
|
||||||
|
└─ node_modules/ # 可选:@napi-rs/canvas(用于高质量缩略图渲染)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会:
|
||||||
|
- 定位到所选文件的 item 文件夹;
|
||||||
|
- 扫描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma` 等常见后缀)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**;
|
||||||
|
- 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。
|
||||||
|
2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。
|
||||||
|
3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持:
|
||||||
|
- 动画列表切换
|
||||||
|
- 皮肤切换
|
||||||
|
- 播放 / 暂停(空格)
|
||||||
|
- 循环开关
|
||||||
|
- 播放速度调节
|
||||||
|
- 自动适配并居中
|
||||||
|
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
|
||||||
|
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关于新版/旧版二进制
|
||||||
|
|
||||||
|
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
|
||||||
|
- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时加载。插件会检测到这种情况并给出提示(缩略图显示占位图),建议改用 **JSON** 导出即可正常预览。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可选依赖说明
|
||||||
|
|
||||||
|
`@napi-rs/canvas` 仅用于缩略图的高质量渲染。它并非必需:
|
||||||
|
- 若保留 `node_modules/`(当前平台原生二进制),缩略图会渲染骨骼形象。
|
||||||
|
- 若删除 `node_modules/`,插件仍可正常预览动画,缩略图会显示一条纯 JS 占位图。
|
||||||
|
|
||||||
|
如需在多个平台分发,请安装对应平台的 `@napi-rs/canvas` 原生二进制。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const fs=require('fs');
|
||||||
|
const path=require('path');
|
||||||
|
const utils=require('./js/spine-utils.js');
|
||||||
|
const base='/Users/tianmokeji/Desktop/特效/特效.library/images';
|
||||||
|
const src=base+'/MTS9JWMH7TLP1.info/spineboy.atlas';
|
||||||
|
const itemDir=path.dirname(src);
|
||||||
|
console.log('itemDir:', itemDir);
|
||||||
|
console.log('metadata 存在:', fs.existsSync(path.join(itemDir,'metadata.json')));
|
||||||
|
console.log('isEagleItemDir:', utils.isEagleItemDir(itemDir));
|
||||||
|
console.log('目录内容:', fs.readdirSync(itemDir));
|
||||||
|
const roots=utils.getSearchRoots(src);
|
||||||
|
console.log('getSearchRoots 返回:', roots);
|
||||||
|
console.log('root 数:', roots.length);
|
||||||
+140
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+11055
File diff suppressed because it is too large.
Load diff
+11714
File diff suppressed because it is too large.
Load diff
+13723
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "f6602400-1327-485c-98e0-78ae9fded460",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"platform": "all",
|
||||||
|
"arch": "all",
|
||||||
|
"name": "SpineViewer",
|
||||||
|
"logo": "/logo.png",
|
||||||
|
"keywords": [
|
||||||
|
"spine",
|
||||||
|
"骨骼动画",
|
||||||
|
"动画预览",
|
||||||
|
"skel",
|
||||||
|
"atlas"
|
||||||
|
],
|
||||||
|
"devTools": false,
|
||||||
|
"preview": {
|
||||||
|
"skel,skel.bytes,json,atlas,atlas.txt": {
|
||||||
|
"thumbnail": {
|
||||||
|
"path": "thumbnail/spine.js",
|
||||||
|
"size": 400,
|
||||||
|
"allowZoom": false
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"path": "viewer/spine.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+212
@@ -0,0 +1,212 @@
|
|||||||
|
{
|
||||||
|
"name": "spineviewer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "spineviewer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.66"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.66",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.66",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-77Yq9yaUYN90zCovYOpw7LhidJiswU9wLIWWBGF6iiEJyQdt6tkiXpGRZpOMJVO70afkcdc4T7532cxMIBhk0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-cz3aJ06b8BZGtwRxKTiE0OVUlB17MH8j+BnE4A5+wD9aD1guCCqECsz+k7tpXdAdTAYKRIz2pq6ZuiJ76NyUbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-szIWqJgFm2OTyGzM+hSiJOaOtjI73VYRC2KN30zZTt7i1+0sgpm5exK5ltDBPOmCdnLt7SbUfpInLj8VvxYlKA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-h/TZJFc6JLvp8FwbA5mu+yXiblN0iKqshU7xzd6L+ks5uNYgjS7XWLkNiyPQkMaXQgVczOJfZy7r4NSPK3V8Hg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-RGFUdBdi0Xmf+TfwZcB89Ap6hDYh4nzyJhXhNJIgve6ELrIPFhf7sDHvUHxjgW0YzczGoo+ophyCm03cJggu+w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-2cFViDIZ0xQlAHyJmyym+rj3p04V16vgAiz64sCAfwOOiW6e19agv1HQWHUsro3G2lF3PaHGAnp0WRPXGqLOfg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-Vm5ZWS2RDPeBpnfx83eJpZfJT07xl0jqp8d83PklKqiDNa3BmDZZ/uuI40/ICgejGLymXXYo5N21b7oAxhRTSA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-/ptGBhErNBCgWff3khtuEjhiiYWf70oWvBPRj8y5EMB0nLYpve7RxxFnavVvxN49kJ0MQHRIwgfyd47RSOOKPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-XunvXisTkIG+bpq6BcXmsUstoLX3RLS6N9Uz9Pg9RpWIMeM6ObR5shr3NgpGRJq93769I1hS4mJW0DX2Au3WBw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.66",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
|
||||||
|
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "spineviewer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
|
||||||
|
"main": "thumbnail/spine.js",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.66"
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
const fs=require('fs');
|
||||||
|
const path=require('path');
|
||||||
|
const utils=require('./js/spine-utils.js');
|
||||||
|
const render=require('./js/spine-render.js');
|
||||||
|
const base='/Users/tianmokeji/Desktop/特效/特效.library/images';
|
||||||
|
// 找一个有效的 json
|
||||||
|
const j=base+'/MTS9A4QKK0ALE.info/spineboy-pro.json';
|
||||||
|
const a=utils.discoverAssets(j);
|
||||||
|
if(!a.skeletonPath){console.log('未找到骨骼');process.exit(1);}
|
||||||
|
console.log('使用骨骼:', a.skeletonPath);
|
||||||
|
console.log('图集:', a.atlasPath);
|
||||||
|
(async()=>{
|
||||||
|
const v=utils.detectVersion(a.skeletonPath);
|
||||||
|
const rd=utils.chooseRuntimeDir(v,a.isBinary);
|
||||||
|
console.log('version:', v, 'runtimeDir:', rd);
|
||||||
|
const spine=render.loadRuntime(rd);
|
||||||
|
const atlas=await render.buildTextureAtlas(spine, fs.readFileSync(a.atlasPath,'utf8'), path.dirname(a.skeletonPath), a.texturePaths);
|
||||||
|
const sd=render.readSkeletonData(spine, a.skeletonPath, atlas, a.isBinary);
|
||||||
|
console.log('动画数量:', sd.animations.length);
|
||||||
|
for(const anim of sd.animations){
|
||||||
|
console.log(' - '+anim.name+' duration='+anim.duration+'s');
|
||||||
|
}
|
||||||
|
if(sd.animations[0]){
|
||||||
|
console.log('第一个动画名:', sd.animations[0].name, ' duration:', sd.animations[0].duration);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const fs=require('fs');
|
||||||
|
const thumb=require('./thumbnail/spine.js');
|
||||||
|
const base='/Users/tianmokeji/Desktop/特效/特效.library/images';
|
||||||
|
(async()=>{
|
||||||
|
const atlas=base+'/MTS71AFMHS8JW.info/spineboy.atlas';
|
||||||
|
const json=base+'/MTS71AFLNZODR.info/spineboy-pro.json';
|
||||||
|
for(let i=1;i<=4;i++){
|
||||||
|
for(const [tag,src] of [['atlas',atlas],['json',json]]){
|
||||||
|
const dest='/tmp/shot-'+tag+'-'+i+'.png';
|
||||||
|
const item={width:0,height:0};
|
||||||
|
try{
|
||||||
|
await thumb({src,dest,item});
|
||||||
|
console.log('run',i,tag,'OK',fs.statSync(dest).size+'b');
|
||||||
|
}catch(e){ console.log('run',i,tag,'REJECT',e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const fs=require('fs');
|
||||||
|
const thumb=require('./thumbnail/spine.js');
|
||||||
|
const base='/Users/tianmokeji/Desktop/特效/特效.library/images';
|
||||||
|
const atlas=base+'/MTS71AFMHS8JW.info/spineboy.atlas';
|
||||||
|
const json=base+'/MTS71AFLNZODR.info/spineboy-pro.json';
|
||||||
|
(async()=>{
|
||||||
|
for(let i=1;i<=6;i++){
|
||||||
|
// 并发同时生成 atlas + json(模拟 Eagle 同时为两个文件生成缩略图)
|
||||||
|
const results=await Promise.all([
|
||||||
|
thumb({src:atlas,dest:'/tmp/c-atlas-'+i+'.png',item:{width:0,height:0}}).then(()=>({n:'atlas',ok:true})).catch(e=>({n:'atlas',ok:false,err:e.message})),
|
||||||
|
thumb({src:json,dest:'/tmp/c-json-'+i+'.png',item:{width:0,height:0}}).then(()=>({n:'json',ok:true})).catch(e=>({n:'json',ok:false,err:e.message})),
|
||||||
|
]);
|
||||||
|
console.log('round',i,'->', results.map(r=>(r.n+':'+(r.ok?'OK':'FAIL('+r.err+')'))).join(' '));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const icns = require('./../js/icns-util.js');
|
||||||
|
const imageSize = require('./../js/image-size.js');
|
||||||
|
|
||||||
|
module.exports = async ({ src, dest, item }) => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// 1. parsing and generate thumbnail file to dest
|
||||||
|
await icns.icns2png(src, dest);
|
||||||
|
let size = await imageSize(dest);
|
||||||
|
|
||||||
|
// 2. Check if the result is correct
|
||||||
|
if (!fs.existsSync(dest) || size.width === 0) {
|
||||||
|
return reject(new Error(`icns file thumbnail generate fail.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. update the item dimensions
|
||||||
|
item.height = size?.height || item.height;
|
||||||
|
item.width = size?.width || item.width;
|
||||||
|
|
||||||
|
// 4. return the result
|
||||||
|
return resolve(item);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const utils = require('./../js/spine-utils.js');
|
||||||
|
const render = require('./../js/spine-render.js');
|
||||||
|
|
||||||
|
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
|
||||||
|
function log(msg) {
|
||||||
|
try {
|
||||||
|
const logPath = path.join(__dirname, '..', 'thumbnails.log');
|
||||||
|
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||||
|
const VERSION = 'v4';
|
||||||
|
|
||||||
|
/** 发现配套资源;带重试,以应对 Eagle 导入时“多文件分批写入库”的时序问题。
|
||||||
|
* 同伴文件(骨骼/图集)可能在 Atlas 的缩略图生成后才落盘,故需等待。
|
||||||
|
* 一旦发现即立即返回(不会傻等满窗口),窗口仅作为最坏情况的上限。
|
||||||
|
* 采用 10 次 × 150ms ≈ 1.5 秒;间隔更密、最快感知同伴落盘。 */
|
||||||
|
async function discoverWithRetry(src, retries = 10, delay = 150) {
|
||||||
|
let assets = null;
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
assets = utils.discoverAssets(src);
|
||||||
|
if (assets.skeletonPath && assets.atlasPath) 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 }
|
||||||
|
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
|
||||||
|
* - dest : 需要生成的缩略图 PNG 路径
|
||||||
|
* - item : 当前 item 对象(可更新宽高)
|
||||||
|
*/
|
||||||
|
module.exports = async ({ src, dest, item }) => {
|
||||||
|
try {
|
||||||
|
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
|
||||||
|
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
|
||||||
|
try {
|
||||||
|
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
|
||||||
|
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
|
||||||
|
if (item && item.isDeleted) {
|
||||||
|
log(' skip: item.isDeleted=true');
|
||||||
|
return resolve(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
|
||||||
|
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
|
||||||
|
const srcExt = utils.extOf(path.basename(src));
|
||||||
|
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
|
||||||
|
await copyImageAsThumb(src, dest);
|
||||||
|
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
|
||||||
|
return resolve(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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)) || ''));
|
||||||
|
item.width = 400; item.height = 400;
|
||||||
|
return resolve(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = utils.detectVersion(assets.skeletonPath);
|
||||||
|
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||||
|
|
||||||
|
if (!runtimeDir) {
|
||||||
|
await writePlaceholder(dest, path.basename(assets.skeletonPath, path.extname(assets.skeletonPath)));
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(dest, png);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const size = imageSizeOf(dest);
|
||||||
|
item.width = size.width || 400;
|
||||||
|
item.height = size.height || 400;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
return Promise.resolve(item);
|
||||||
|
} catch (err) {
|
||||||
|
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
|
||||||
|
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
|
||||||
|
try {
|
||||||
|
await writePlaceholder(dest, 'Spine');
|
||||||
|
item.width = 400;
|
||||||
|
item.height = 400;
|
||||||
|
return resolve(item);
|
||||||
|
} catch (e2) {
|
||||||
|
// 最后兜底:写入一张最小 1x1 PNG
|
||||||
|
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
|
||||||
|
item.width = 400; item.height = 400;
|
||||||
|
return resolve(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolve(item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
|
||||||
|
async function copyImageAsThumb(src, dest) {
|
||||||
|
const ext = path.extname(src).toLowerCase();
|
||||||
|
if (ext === '.png') {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (render.canvasLib) {
|
||||||
|
try {
|
||||||
|
const img = await render.canvasLib.loadImage(src);
|
||||||
|
const w = img.width, h = img.height;
|
||||||
|
const canvas = render.canvasLib.createCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||||
|
return;
|
||||||
|
} catch (e) { /* fall through */ }
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 PNG 宽高(仅支持 PNG 头部) */
|
||||||
|
function imageSizeOf(file) {
|
||||||
|
const buf = fs.readFileSync(file);
|
||||||
|
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
|
||||||
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
|
||||||
|
async function writePlaceholder(dest, text) {
|
||||||
|
const w = 400, h = 400;
|
||||||
|
// 深色底
|
||||||
|
const pixels = Buffer.alloc(w * h * 4);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = (y * w + x) * 4;
|
||||||
|
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 简单的斜线纹理,让占位图不单调
|
||||||
|
for (let i = 0; i < w + h; i += 8) {
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const x = Math.min(w - 1, i - k);
|
||||||
|
const y = Math.min(h - 1, k);
|
||||||
|
const idx = (y * w + x) * 4;
|
||||||
|
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(dest, encodePng(w, h, pixels));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
|
||||||
|
function encodePng(width, height, rgba) {
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // color type RGBA
|
||||||
|
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||||
|
const ihdrChunk = chunk('IHDR', ihdr);
|
||||||
|
|
||||||
|
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
raw[y * (width * 4 + 1)] = 0; // filter type 0
|
||||||
|
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||||
|
}
|
||||||
|
const idat = zlib.deflateSync(raw);
|
||||||
|
const idatChunk = chunk('IDAT', idat);
|
||||||
|
const iend = chunk('IEND', Buffer.alloc(0));
|
||||||
|
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(data.length, 0);
|
||||||
|
const typeBuf = Buffer.from(type, 'ascii');
|
||||||
|
const crcBuf = Buffer.alloc(4);
|
||||||
|
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||||
|
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c, crc = 0xFFFFFFFF;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
c = (crc ^ buf[i]) & 0xFF;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
crc = (crc >>> 8) ^ c;
|
||||||
|
}
|
||||||
|
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>ICNS Viewer</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#viewer {
|
||||||
|
pointer-events: none;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img id="viewer"/>
|
||||||
|
<script>
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const filePath = urlParams.get('path');
|
||||||
|
const width = urlParams.get('width');
|
||||||
|
const height = urlParams.get('height');
|
||||||
|
const theme = urlParams.get('theme');
|
||||||
|
const lang = urlParams.get('lang');
|
||||||
|
|
||||||
|
const viewer = document.querySelector('#viewer');
|
||||||
|
|
||||||
|
// 1. Load the thumbnail image first
|
||||||
|
// 👍 Avoid loading for too long, and UI has no content
|
||||||
|
viewer.src = filePath.replace(".icns", "_thumbnail.png");
|
||||||
|
|
||||||
|
// 2. Load the file and replace thumbnail
|
||||||
|
(async function() {
|
||||||
|
const icns = require('./../js/icns-util.js');
|
||||||
|
let buffer = await icns.icns2buffer(filePath);
|
||||||
|
let base64 = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||||
|
viewer.src = base64;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spine 预览</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--panel: #252526;
|
||||||
|
--panel-2: #2d2d30;
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--text-dim: #9d9d9d;
|
||||||
|
--accent: #4fc1ff;
|
||||||
|
--border: #3c3c3c;
|
||||||
|
--error: #f14c4c;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f3f4f6;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #f0f0f0;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-dim: #6b6b6b;
|
||||||
|
--accent: #0078d4;
|
||||||
|
--border: #ddd;
|
||||||
|
--error: #d13438;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#app { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
#toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
padding: 8px 12px; background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border); z-index: 10;
|
||||||
|
}
|
||||||
|
#toolbar .spacer { flex: 1; }
|
||||||
|
#toolbar label { color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
|
||||||
|
select, input[type="range"] {
|
||||||
|
background: var(--panel-2); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px; padding: 4px 6px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--panel-2); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--border); }
|
||||||
|
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
#stage { position: relative; flex: 1; overflow: hidden; }
|
||||||
|
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
#status {
|
||||||
|
position: absolute; bottom: 10px; left: 12px; color: var(--text-dim);
|
||||||
|
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
#timelineBar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px; background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#timelineBar .tl-label { color: var(--text-dim); font-size: 12px; white-space: nowrap; }
|
||||||
|
#timelineBar .tl-wrap { position: relative; flex: 1; padding: 8px 0; }
|
||||||
|
#timelineBar input[type="range"] {
|
||||||
|
display: block; width: 100%; margin: 0; accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
#keyframes { position: relative; height: 8px; margin-top: 2px; }
|
||||||
|
#keyframes span {
|
||||||
|
position: absolute; top: 0; width: 2px; height: 8px;
|
||||||
|
background: var(--accent); opacity: .55; transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
#timeLabel {
|
||||||
|
color: var(--text-dim); font-size: 12px; min-width: 110px;
|
||||||
|
text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
}
|
||||||
|
#overlay {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg); z-index: 5; text-align: center;
|
||||||
|
}
|
||||||
|
#overlay .box { max-width: 520px; padding: 20px; }
|
||||||
|
#overlay .title { font-size: 16px; margin-bottom: 8px; color: var(--error); }
|
||||||
|
#overlay .msg { color: var(--text-dim); white-space: pre-wrap; word-break: break-all; }
|
||||||
|
#overlay.hidden { display: none; }
|
||||||
|
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); }
|
||||||
|
#loading.hidden { display: none; }
|
||||||
|
.spinner {
|
||||||
|
width: 34px; height: 34px; border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent); border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
#hideOthers { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="toolbar">
|
||||||
|
<label>动画
|
||||||
|
<select id="animSelect" disabled></select>
|
||||||
|
</label>
|
||||||
|
<label>皮肤
|
||||||
|
<select id="skinSelect" disabled></select>
|
||||||
|
</label>
|
||||||
|
<button id="playBtn" title="播放/暂停 (空格)">⏸ 暂停</button>
|
||||||
|
<label><input type="checkbox" id="loopCheck" checked> 循环</label>
|
||||||
|
<label>速度
|
||||||
|
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
|
||||||
|
<span id="speedVal">1.0x</span>
|
||||||
|
</label>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
|
||||||
|
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
|
||||||
|
<button id="reloadBtn" title="重新加载目录">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="stage">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div id="status"></div>
|
||||||
|
<div id="loading"><div class="spinner"></div></div>
|
||||||
|
<div id="overlay" class="hidden">
|
||||||
|
<div class="box">
|
||||||
|
<div class="title" id="errorTitle">无法预览</div>
|
||||||
|
<div class="msg" id="errorMsg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="timelineBar">
|
||||||
|
<span class="tl-label">帧</span>
|
||||||
|
<div class="tl-wrap">
|
||||||
|
<input type="range" id="timeline" min="0" max="1" step="0.001" value="0" disabled>
|
||||||
|
<div id="keyframes"></div>
|
||||||
|
</div>
|
||||||
|
<span id="timeLabel">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 使用 Node 集成提供的模块
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const utils = require('../js/spine-utils.js');
|
||||||
|
|
||||||
|
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
|
||||||
|
function log(msg) {
|
||||||
|
try {
|
||||||
|
const logPath = path.join(__dirname, '..', 'thumbnails.log');
|
||||||
|
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 读取 URL 参数 ----------
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const filePath = urlParams.get('path');
|
||||||
|
const width = urlParams.get('width');
|
||||||
|
const height = urlParams.get('height');
|
||||||
|
const theme = urlParams.get('theme');
|
||||||
|
const lang = urlParams.get('lang');
|
||||||
|
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const overlay = document.getElementById('overlay');
|
||||||
|
const loadingEl = document.getElementById('loading');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
const timeLabel = document.getElementById('timeLabel');
|
||||||
|
const keyframesEl = document.getElementById('keyframes');
|
||||||
|
|
||||||
|
// ---------- 全局状态 ----------
|
||||||
|
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||||||
|
let renderer = null;
|
||||||
|
let currentAnim = 0, currentSkin = null;
|
||||||
|
let playing = true, loop = true, speed = 1;
|
||||||
|
let timeKeeper = null, lastTime = 0, running = false;
|
||||||
|
let seeking = false; // 用户正在拖动时间轴
|
||||||
|
let duration = 0; // 当前动画时长
|
||||||
|
let wasPlayingBeforeSeek = false; // 记录拖动前是否在播放
|
||||||
|
const FPS = 30;
|
||||||
|
|
||||||
|
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
||||||
|
let panX = 0, panY = 0, zoom = 1;
|
||||||
|
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
||||||
|
let dragging = false; // 是否正在拖拽
|
||||||
|
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
||||||
|
|
||||||
|
function showError(title, msg) {
|
||||||
|
loadingEl.classList.add('hidden');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
document.getElementById('errorTitle').textContent = title;
|
||||||
|
document.getElementById('errorMsg').textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) { statusEl.textContent = msg; }
|
||||||
|
|
||||||
|
// ---------- 动态加载本地 Spine 运行时 ----------
|
||||||
|
function loadRuntime(dir) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = '../lib/' + dir + '/spine-canvas.js';
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1x1 灰色占位贴图 data URI
|
||||||
|
const PLACEHOLDER_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
||||||
|
|
||||||
|
// ---------- 在浏览器中构造 TextureAtlas(跨 Eagle item 文件夹加载贴图)----------
|
||||||
|
// assets.texturePaths: { atlas 引用的贴图名 -> 绝对路径 }
|
||||||
|
function buildBrowserAtlas(spine, atlasText, assets) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(async () => {
|
||||||
|
// 1) 预先读取所有页面贴图,转成 data URI 并用 Image 加载
|
||||||
|
const pageNames = [];
|
||||||
|
try { pageNames.push(...utils.parseAtlasPages(atlasText)); } catch (e) { /* ignore */ }
|
||||||
|
const imgMap = {};
|
||||||
|
await Promise.all(pageNames.map((name) => {
|
||||||
|
return new Promise((res) => {
|
||||||
|
const p = (assets.texturePaths && assets.texturePaths[name]);
|
||||||
|
if (!p || !fs.existsSync(p)) { res(); return; }
|
||||||
|
const ext = path.extname(p).toLowerCase();
|
||||||
|
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(p);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { imgMap[name] = img; res(); };
|
||||||
|
img.onerror = () => res();
|
||||||
|
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
|
||||||
|
} catch (e) { res(); }
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2) 生成 Textures
|
||||||
|
const makeTexture = (name) => {
|
||||||
|
let img = imgMap[name];
|
||||||
|
if (!img) {
|
||||||
|
const ph = new Image(); ph.src = PLACEHOLDER_IMG;
|
||||||
|
img = ph;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3) 构建 Atlas(3.x 用构造器回调,4.x 用 setTexture)
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 计算骨骼包围盒(跨版本兼容)----------
|
||||||
|
function getBounds(sk) {
|
||||||
|
try {
|
||||||
|
if (typeof sk.getBoundsRect === 'function') {
|
||||||
|
const r = sk.getBoundsRect();
|
||||||
|
if (r && (r.width !== undefined)) return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||||
|
}
|
||||||
|
if (typeof sk.getBounds === 'function') {
|
||||||
|
const offset = new spine.Vector2();
|
||||||
|
const size = new spine.Vector2();
|
||||||
|
sk.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 适配并居中绘制一帧 ----------
|
||||||
|
// Spine 世界坐标 Y 轴向上,Canvas 2D Y 轴向下,绘制时需翻转 Y。
|
||||||
|
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
||||||
|
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
||||||
|
let fitScale = 1, fitCx = 0, fitCy = 0;
|
||||||
|
function fitToCanvas() {
|
||||||
|
const bounds = getBounds(skeleton);
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||||
|
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
|
||||||
|
}
|
||||||
|
const pad = 0.1;
|
||||||
|
const sx = (cw * (1 - pad * 2)) / bounds.width;
|
||||||
|
const sy = (ch * (1 - pad * 2)) / bounds.height;
|
||||||
|
baseFitScale = Math.min(sx, sy);
|
||||||
|
if (!isFinite(baseFitScale) || baseFitScale <= 0) baseFitScale = 1;
|
||||||
|
baseFitCx = bounds.x + bounds.width / 2;
|
||||||
|
baseFitCy = bounds.y + bounds.height / 2;
|
||||||
|
// 应用用户缩放;骨骼中心保持为默认中心(平移仅作用于屏幕坐标)
|
||||||
|
fitScale = baseFitScale * zoom;
|
||||||
|
fitCx = baseFitCx;
|
||||||
|
fitCy = baseFitCy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
if (canvas.width !== cw * devicePixelRatio || canvas.height !== ch * devicePixelRatio) {
|
||||||
|
canvas.width = cw * devicePixelRatio;
|
||||||
|
canvas.height = ch * devicePixelRatio;
|
||||||
|
}
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
if (!skeleton || !renderer) return;
|
||||||
|
ctx.save();
|
||||||
|
// 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。
|
||||||
|
// panX/panY 作为屏幕坐标偏移叠加,支持上下左右拖动画布。
|
||||||
|
ctx.translate(cw / 2 + panX, ch / 2 + panY);
|
||||||
|
ctx.scale(fitScale, -fitScale);
|
||||||
|
ctx.translate(-fitCx, -fitCy);
|
||||||
|
renderer.draw(skeleton);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 更新动画状态 ----------
|
||||||
|
function updateWorld(sk) {
|
||||||
|
if (sk.updateWorldTransform) {
|
||||||
|
if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update);
|
||||||
|
else sk.updateWorldTransform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)----------
|
||||||
|
function generateThumbnail() {
|
||||||
|
if (!skeleton || !renderer) { setStatus('尚未加载骨骼,无法生成缩略图'); return; }
|
||||||
|
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||||||
|
playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
if (state) state.apply(skeleton);
|
||||||
|
updateWorld(skeleton);
|
||||||
|
|
||||||
|
const size = 400;
|
||||||
|
const bounds = getBounds(skeleton);
|
||||||
|
// 离屏 canvas 用于导出 PNG
|
||||||
|
const off = document.createElement('canvas');
|
||||||
|
off.width = size; off.height = size;
|
||||||
|
const octx = off.getContext('2d');
|
||||||
|
octx.fillStyle = '#1e1e1e';
|
||||||
|
octx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||||
|
const pad = 0.1;
|
||||||
|
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||||
|
const cx = bounds.x + bounds.width / 2;
|
||||||
|
const cy = bounds.y + bounds.height / 2;
|
||||||
|
octx.save();
|
||||||
|
octx.translate(size / 2, size / 2);
|
||||||
|
octx.scale(scale, -scale);
|
||||||
|
octx.translate(-cx, -cy);
|
||||||
|
// 用独立的 renderer 绑定离屏 ctx
|
||||||
|
let offRenderer = renderer;
|
||||||
|
try {
|
||||||
|
if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx);
|
||||||
|
else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx);
|
||||||
|
} catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ }
|
||||||
|
if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true;
|
||||||
|
offRenderer.draw(skeleton);
|
||||||
|
octx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 PNG 并保存到文件旁(覆盖 Eagle 默认缩略图 <stem>_thumbnail.png)
|
||||||
|
// 用 WebP 编码以显著减小体积(Eagle 的缩略图本身也是 WebP 格式,尽管扩展名为 .png)
|
||||||
|
let out = null;
|
||||||
|
try {
|
||||||
|
out = off.toDataURL('image/webp');
|
||||||
|
} catch (e) {
|
||||||
|
out = off.toDataURL('image/png'); // 回退 PNG
|
||||||
|
}
|
||||||
|
const b64 = out.split(',')[1];
|
||||||
|
const buf = Buffer.from(b64, 'base64');
|
||||||
|
|
||||||
|
// 覆盖默认缩略图:用文件基名(stem)+ _thumbnail.png,即 Eagle 管理缩略图的文件名
|
||||||
|
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
|
||||||
|
let outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(outPath, buf);
|
||||||
|
setStatus('已更新缩略图: ' + path.basename(outPath) + ' (' + (buf.length / 1024).toFixed(1) + 'KB)');
|
||||||
|
log('生成缩略图: ' + outPath + ' (' + buf.length + 'B)');
|
||||||
|
} catch (e) {
|
||||||
|
// 若无法写入(无目录写权限),回退为下载
|
||||||
|
try {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = out; a.download = stem + '_thumbnail.png';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setStatus('已通过下载保存缩略图');
|
||||||
|
} catch (e2) {
|
||||||
|
setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 渲染主循环 ----------
|
||||||
|
function renderLoop(now) {
|
||||||
|
if (!running) return;
|
||||||
|
requestAnimationFrame(renderLoop);
|
||||||
|
const dt = (now - lastTime) / 1000;
|
||||||
|
lastTime = now;
|
||||||
|
if (skeleton && state) {
|
||||||
|
if (playing) {
|
||||||
|
// Spine 运行时自身处理循环;持续 update 即可。
|
||||||
|
state.update(dt * speed);
|
||||||
|
state.apply(skeleton);
|
||||||
|
}
|
||||||
|
updateWorld(skeleton);
|
||||||
|
draw();
|
||||||
|
const time = state.getCurrent(0);
|
||||||
|
if (time) {
|
||||||
|
setStatus(animName(time.animation ? time.animation.name : '') + ' · ' + time.time.toFixed(2) + 's');
|
||||||
|
// 同步时间轴(若用户未在拖动),用取模避免循环时滑块越界
|
||||||
|
if (!seeking) {
|
||||||
|
const t = (loop && duration > 0) ? (time.time % duration) : time.time;
|
||||||
|
// 归一化到 [0, duration],避免越界
|
||||||
|
const norm = (duration > 0) ? Math.max(0, Math.min(t, duration)) : 0;
|
||||||
|
if (timeline.max !== String(Math.max(duration, 0.001))) timeline.max = String(Math.max(duration, 0.001));
|
||||||
|
timeline.value = String(norm);
|
||||||
|
timeLabel.textContent = frameOf(norm) + ' / ' + frameOf(duration) + ' 帧';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帧号换算(默认 30fps)
|
||||||
|
function frameOf(seconds) {
|
||||||
|
return Math.round((seconds || 0) * FPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animName(name) {
|
||||||
|
const sel = document.getElementById('animSelect');
|
||||||
|
return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 填充 UI ----------
|
||||||
|
function fillAnimations() {
|
||||||
|
const sel = document.getElementById('animSelect');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
(skeletonData.animations || []).forEach((a, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i; opt.textContent = a.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.disabled = skeletonData.animations.length <= 1;
|
||||||
|
if (skeletonData.animations.length > 0) sel.value = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSkins() {
|
||||||
|
const sel = document.getElementById('skinSelect');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
(skeletonData.skins || []).forEach((s, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i; opt.textContent = s.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.disabled = (skeletonData.skins || []).length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnimationByName(name) {
|
||||||
|
if (!state) return;
|
||||||
|
const anim = findAnimByName(name);
|
||||||
|
state.setAnimation(0, name, loop);
|
||||||
|
updateTimelineDuration(anim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
|
||||||
|
function findAnimByName(name) {
|
||||||
|
const arr = (skeletonData && skeletonData.animations) || [];
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (arr[i] && arr[i].name === name) return arr[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
||||||
|
function updateTimelineDuration(anim) {
|
||||||
|
duration = (anim && anim.duration) ? anim.duration : 0;
|
||||||
|
timeline.max = String(Math.max(duration, 0.001));
|
||||||
|
timeline.value = '0';
|
||||||
|
timeline.disabled = duration <= 0;
|
||||||
|
timeLabel.textContent = '0 / ' + frameOf(duration) + ' 帧';
|
||||||
|
renderKeyframeMarkers(anim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出)
|
||||||
|
function renderKeyframeMarkers(anim) {
|
||||||
|
keyframesEl.innerHTML = '';
|
||||||
|
if (!anim || !anim.timelines || duration <= 0) return;
|
||||||
|
const times = new Set();
|
||||||
|
for (const tl of anim.timelines) {
|
||||||
|
if (!tl || !tl.frames) continue;
|
||||||
|
// frames 数组在 3.x/4.x 结构不同:3.x 是 [time, value, time, value...],4.x 是 [[time,value],...] 或带 keyframes
|
||||||
|
try {
|
||||||
|
if (Array.isArray(tl.frames[0])) {
|
||||||
|
for (const f of tl.frames) { if (Array.isArray(f) && typeof f[0] === 'number') times.add(f[0]); }
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < tl.frames.length; i += 2) {
|
||||||
|
if (typeof tl.frames[i] === 'number') times.add(tl.frames[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
const sorted = [...times].filter((t) => t >= 0 && t <= duration).sort((a, b) => a - b);
|
||||||
|
// 限制数量,避免过多标记
|
||||||
|
const maxMarkers = 200;
|
||||||
|
const step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
|
||||||
|
for (let i = 0; i < sorted.length; i += step) {
|
||||||
|
const t = sorted[i];
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%';
|
||||||
|
keyframesEl.appendChild(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳到指定时间点:暂停播放,重置动画到目标时间
|
||||||
|
function seekTo(time) {
|
||||||
|
if (!state || !skeleton) return;
|
||||||
|
const track = state.getCurrent(0);
|
||||||
|
const anim = track ? track.animation : null;
|
||||||
|
playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
try {
|
||||||
|
if (anim) {
|
||||||
|
state.setAnimation(0, anim.name, loop);
|
||||||
|
}
|
||||||
|
state.update(Math.max(0, time));
|
||||||
|
state.apply(skeleton);
|
||||||
|
updateWorld(skeleton);
|
||||||
|
draw();
|
||||||
|
timeLabel.textContent = frameOf(time) + ' / ' + frameOf(duration) + ' 帧';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySkinByName(name) {
|
||||||
|
if (!skeleton) return;
|
||||||
|
if (skeleton.setSkinByName) skeleton.setSkinByName(name);
|
||||||
|
else if (skeleton.setSkin && skeleton.data.findSkin) skeleton.setSkin(skeleton.data.findSkin(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 非 Spine 图片回退:直接显示图片 ----------
|
||||||
|
function showImageFallback(file) {
|
||||||
|
try {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
|
||||||
|
const data = fs.readFileSync(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
loadingEl.classList.add('hidden');
|
||||||
|
running = false;
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
canvas.width = cw * devicePixelRatio; canvas.height = ch * devicePixelRatio;
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
const scale = Math.min(cw / img.width, ch / img.height);
|
||||||
|
const w = img.width * scale, h = img.height * scale;
|
||||||
|
ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);
|
||||||
|
setStatus('图片预览(未检测到配套 Spine 资源)');
|
||||||
|
};
|
||||||
|
img.onerror = () => showError('图片加载失败', file);
|
||||||
|
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
|
||||||
|
} catch (e) {
|
||||||
|
showError('图片加载失败', (e && e.message) || String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载资源并与运行时交互 ----------
|
||||||
|
async function loadAssets() {
|
||||||
|
const assets = utils.discoverAssets(filePath);
|
||||||
|
|
||||||
|
if (!assets.skeletonPath || !assets.atlasPath) {
|
||||||
|
if (!assets.skeletonPath) {
|
||||||
|
showError('未找到骨骼文件', '未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。');
|
||||||
|
} else {
|
||||||
|
showError('未找到 Atlas', '未能定位到配套的 .atlas / .atlas.txt 文件。');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测版本并选择运行时
|
||||||
|
const version = utils.detectVersion(assets.skeletonPath);
|
||||||
|
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||||
|
if (!runtimeDir) {
|
||||||
|
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('verLabel').textContent = 'Spine ' + (version || runtimeDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadRuntime(runtimeDir);
|
||||||
|
} catch (e) {
|
||||||
|
showError('运行时加载失败', e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取骨骼数据(直接用 Node fs 读取,兼容跨文件夹)
|
||||||
|
let skelData;
|
||||||
|
if (assets.isBinary) {
|
||||||
|
skelData = new Uint8Array(fs.readFileSync(assets.skeletonPath));
|
||||||
|
} else {
|
||||||
|
skelData = fs.readFileSync(assets.skeletonPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 atlas 文本并构造 TextureAtlas(跨 item 文件夹加载贴图)
|
||||||
|
let atlas;
|
||||||
|
try {
|
||||||
|
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
|
||||||
|
atlas = await buildBrowserAtlas(spine, atlasText, assets);
|
||||||
|
} catch (e) {
|
||||||
|
showError('Atlas 加载失败', (e && e.message) || String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建骨骼加载器
|
||||||
|
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||||
|
const loader = assets.isBinary ? new spine.SkeletonBinary(atlasLoader) : new spine.SkeletonJson(atlasLoader);
|
||||||
|
loader.scale = 1;
|
||||||
|
|
||||||
|
let skeletonData2;
|
||||||
|
if (assets.isBinary) {
|
||||||
|
skeletonData2 = loader.readSkeletonData(skelData);
|
||||||
|
} else {
|
||||||
|
skeletonData2 = loader.readSkeletonData(skelData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.6/3.7 的二进制骨骼无法由官方 JS 运行时解析
|
||||||
|
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
|
||||||
|
const isOldBin = assets.isBinary && version && parseFloat(version) < 3.8;
|
||||||
|
showError(isOldBin ? '版本二进制暂不支持' : '骨骼数据为空',
|
||||||
|
isOldBin
|
||||||
|
? 'Spine ' + version + ' 的二进制骨骼(.skel / .skel.bytes)无法由官方 JS 运行时解析。\n\n建议在 Spine 中改用 JSON 导出(`.json`)后再导入预览。'
|
||||||
|
: '未能读取到有效的骨骼数据。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
skeletonData = skeletonData2;
|
||||||
|
|
||||||
|
// 创建骨架与动画状态
|
||||||
|
skeleton = new spine.Skeleton(skeletonData);
|
||||||
|
stateData = new spine.AnimationStateData(skeletonData);
|
||||||
|
stateData.defaultMix = 0.2;
|
||||||
|
state = new spine.AnimationState(stateData);
|
||||||
|
|
||||||
|
renderer = (spine.canvas && spine.canvas.SkeletonRenderer) ? new spine.canvas.SkeletonRenderer(ctx) : new spine.SkeletonRenderer(ctx);
|
||||||
|
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
|
||||||
|
if (renderer && 'triangleRendering' in renderer) renderer.triangleRendering = true;
|
||||||
|
|
||||||
|
fillAnimations();
|
||||||
|
fillSkins();
|
||||||
|
|
||||||
|
// 默认播放第一个“有时长”的动画(避免选中 duration=0 的空动画导致时间轴失效)
|
||||||
|
if (skeletonData.animations.length > 0) {
|
||||||
|
let defaultAnim = skeletonData.animations[0];
|
||||||
|
for (const anim of skeletonData.animations) {
|
||||||
|
if (anim.duration > 0) { defaultAnim = anim; break; }
|
||||||
|
}
|
||||||
|
state.setAnimation(0, defaultAnim.name, loop);
|
||||||
|
// 同步下拉框选中项
|
||||||
|
const sel = document.getElementById('animSelect');
|
||||||
|
const idx = skeletonData.animations.indexOf(defaultAnim);
|
||||||
|
if (idx >= 0) sel.value = String(idx);
|
||||||
|
updateTimelineDuration(defaultAnim);
|
||||||
|
} else {
|
||||||
|
updateTimelineDuration(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorld(skeleton);
|
||||||
|
fitToCanvas();
|
||||||
|
draw();
|
||||||
|
|
||||||
|
loadingEl.classList.add('hidden');
|
||||||
|
running = true;
|
||||||
|
lastTime = performance.now();
|
||||||
|
requestAnimationFrame(renderLoop);
|
||||||
|
updatePlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI 事件绑定 ----------
|
||||||
|
function updatePlayButton() {
|
||||||
|
const btn = document.getElementById('playBtn');
|
||||||
|
btn.textContent = playing ? '⏸ 暂停' : '▶ 播放';
|
||||||
|
btn.classList.toggle('active', !playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('playBtn').addEventListener('click', () => {
|
||||||
|
playing = !playing; updatePlayButton();
|
||||||
|
});
|
||||||
|
document.getElementById('animSelect').addEventListener('change', (e) => {
|
||||||
|
const idx = parseInt(e.target.value, 10);
|
||||||
|
const anims = skeletonData.animations;
|
||||||
|
if (anims && anims[idx]) setAnimationByName(anims[idx].name);
|
||||||
|
});
|
||||||
|
document.getElementById('skinSelect').addEventListener('change', (e) => {
|
||||||
|
const idx = parseInt(e.target.value, 10);
|
||||||
|
const skins = skeletonData.skins;
|
||||||
|
if (skins && skins[idx]) applySkinByName(skins[idx].name);
|
||||||
|
});
|
||||||
|
document.getElementById('loopCheck').addEventListener('change', (e) => {
|
||||||
|
loop = e.target.checked;
|
||||||
|
const cur = state.getCurrent(0);
|
||||||
|
if (cur) cur.loop = loop;
|
||||||
|
});
|
||||||
|
document.getElementById('speedRange').addEventListener('input', (e) => {
|
||||||
|
speed = parseFloat(e.target.value);
|
||||||
|
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
|
||||||
|
});
|
||||||
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||||||
|
// 时间轴:拖动时实时跳帧
|
||||||
|
timeline.addEventListener('input', (e) => {
|
||||||
|
const t = parseFloat(e.target.value);
|
||||||
|
if (state) seekTo(t);
|
||||||
|
});
|
||||||
|
// 时间轴:按下时暂停动画,拖动结束(松开)恢复播放(若之前是播放状态)
|
||||||
|
timeline.addEventListener('pointerdown', () => {
|
||||||
|
seeking = true;
|
||||||
|
wasPlayingBeforeSeek = playing;
|
||||||
|
playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
});
|
||||||
|
const endTimelineSeek = () => {
|
||||||
|
if (!seeking) return;
|
||||||
|
seeking = false;
|
||||||
|
if (wasPlayingBeforeSeek) {
|
||||||
|
playing = true;
|
||||||
|
updatePlayButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timeline.addEventListener('pointerup', endTimelineSeek);
|
||||||
|
// 即便指针移出滑块松开,也能恢复
|
||||||
|
window.addEventListener('pointerup', endTimelineSeek);
|
||||||
|
|
||||||
|
// ---------- 画布交互:左键拖拽平移,右键回中心,滚轮缩放 ----------
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button === 0) { // 左键
|
||||||
|
dragging = true;
|
||||||
|
dragStartX = e.clientX; dragStartY = e.clientY;
|
||||||
|
dragStartPanX = panX; dragStartPanY = panY;
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
} else if (e.button === 2) { // 右键:回到画布中心
|
||||||
|
panX = 0; panY = 0; zoom = 1;
|
||||||
|
fitToCanvas();
|
||||||
|
draw();
|
||||||
|
setStatus('已回到画布中心');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
// 直接按屏幕像素增量平移(clientX/Y 差值即像素位移,无需换算)
|
||||||
|
panX = dragStartPanX + (e.clientX - dragStartX);
|
||||||
|
panY = dragStartPanY + (e.clientY - dragStartY);
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointerup', () => { dragging = false; });
|
||||||
|
canvas.addEventListener('pointercancel', () => { dragging = false; });
|
||||||
|
// 右键菜单拦截
|
||||||
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
// 滚轮缩放(以画布中心为锚点,更快更跟手)
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // 每格缩放 15%
|
||||||
|
zoom = Math.min(50, Math.max(0.02, zoom * factor));
|
||||||
|
fitToCanvas();
|
||||||
|
draw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'Space') { e.preventDefault(); playing = !playing; updatePlayButton(); }
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!running) return;
|
||||||
|
fitToCanvas();
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 启动 ----------
|
||||||
|
if (!filePath) {
|
||||||
|
showError('缺少文件', '未传入文件路径参数。');
|
||||||
|
} else if (!fs.existsSync(filePath)) {
|
||||||
|
showError('文件不存在', filePath);
|
||||||
|
} else {
|
||||||
|
loadAssets().catch((err) => {
|
||||||
|
showError('加载失败', (err && err.message) ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user