增加对3.7 3.8的二进制读取

This commit is contained in:
tianmo committed 2026-09-09 21:56:13 +08:00
1 parent 1b67933cac
commit 4c5033d2f9
10 files changed
+526 -52

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ Thumbs.db
# Temp test artifacts
/tmp/
*.png.d.ts
tests/.fixtures/
+62
View File
@@ -0,0 +1,62 @@
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.6/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated May 1, 2019. Replaces all prior versions.
*
* Copyright (c) 2013-2019, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY EXPRESS
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
* NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS
* INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
+15 -1
View File
@@ -28,6 +28,7 @@ SpineViewer/
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
├─ js/
│ ├─ spine-utils.js # 资源匹配、版本检测工具
│ ├─ spine-legacy-binary.js # 3.6/3.7 二进制解析兼容层
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
│ ├─ 3.6/ spine-canvas.js
@@ -79,7 +80,20 @@ SpineViewer/
## 关于新版/旧版二进制
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时直接加载。同批存在 **JSON** 时,插件会用 JSON 为 `atlas/json/skel` 三个条目生成相同的骨骼缩略图;没有 JSON 时则显示实际 atlas 贴图预览。
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
### 旧版二进制回归测试
```sh
node tests/fetch-legacy-fixtures.js
node tests/legacy-binary.test.js
```
下载器从官方仓库固定提交获取两版 Spineboy、Goblins、Vine、Stretchyman、Tank、Coin、Raptor 的 JSON/SKEL/atlas。测试素材放在 `tests/.fixtures/`,不提交 Git,也不是插件运行依赖。测试不依赖原生 Canvas,比较附件、关键帧、不同皮肤与缩放下的动画姿势,并调用查看器渲染器检查几何数据。
官方 3.6 Raptor 的 JSON 与 SKEL 在一条 IK 曲线的阶梯插值位置上存在差异,测试明确记录该例外;解析器保留 SKEL 中的原始值。原始代码许可见 `LICENSES/Spine-Legacy-Binary-License.txt`。
---
+242
View File
@@ -0,0 +1,242 @@
'use strict';
// Adapted from Esoteric Software's 3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
const dict = () => Object.create(null);
class Input {
constructor(bytes) {
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.offset = 0;
}
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
byte() { this.need(1); return this.view.getUint8(this.offset++); }
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
varint(positive = true) {
let value = 0;
for (let shift = 0; shift <= 28; shift += 7) {
const b = this.byte();
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
value |= (b & 127) << shift;
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
}
this.fail('无效变长整数');
}
count() { const n = this.varint(); this.need(n); return n; }
string() {
const size = this.varint();
if (size === 0) return null;
this.need(size - 1);
const end = this.offset + size - 1;
let result = '';
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
// code units (including surrogate pairs), rather than string references.
while (this.offset < end) {
const b = this.byte();
if (b < 128) result += String.fromCharCode(b);
else {
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
let code = b & (extra === 1 ? 31 : 15);
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
result += String.fromCharCode(code);
}
}
return result;
}
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
}
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[67]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
const boneList = () => Array.from({ length: r.count() }, boneName);
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2);
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: boneName(), local: r.bool(), relative: r.bool() };
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
function vertices(n) {
if (!r.bool()) return r.floats(n * 2);
const out = [];
for (let i = 0; i < n; i++) {
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
}
return out;
}
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
} else if (type === 2) {
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
a.vertices = vertices(count); a.hull = r.varint();
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
} else if (type === 3) {
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
} else if (type === 5) {
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
} else {
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
if (type === 6) a.end = slotName();
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
if (nonessential) a.color = r.color();
}
return a;
}
const skinNames = [];
function skin(name, isDefault = false) {
const count = r.count(); if (!count && isDefault) return;
const data = root.skins[name] = dict(); skinNames.push(name);
for (let i = 0; i < count; i++) {
const attachments = data[slotName()] = dict();
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
}
}
skin('default', true);
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
const eventNames = [];
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.string(); eventNames.push(name);
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
}
function curve(frame) {
const type = r.byte();
if (type === 1) frame.curve = 'stepped';
else if (type === 2) frame.curve = r.floats(4);
else if (type !== 0) r.fail('未知曲线类型:' + type);
}
function frames(read, curved = true) {
const count = r.count(); if (!count) r.fail('空时间线');
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
}
function animation() {
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.slots[slotName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.bones[boneName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
if (!name) r.fail('未知骨骼时间线');
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.ik).name;
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.paths[r.ref(root.path).name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
for (let k = 0, q = r.count(); k < q; k++) {
const key = r.string();
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
}
}
}
const drawCount = r.count();
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
const f = { time: r.float(), offsets: [] }; let previous = -1;
for (let i = 0, n = r.count(); i < n; i++) {
const index = r.varint(); const offset = r.varint();
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
}
return f;
});
const eventCount = r.count();
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
});
return a;
}
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
return root;
}
function install(spine, runtimeDir) {
if (runtimeDir !== '3.6' && runtimeDir !== '3.7') return spine;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
return loader.readSkeletonData(decode(bytes, runtimeDir));
}
};
return spine;
}
module.exports = { install, decode };
+2 -6
View File
@@ -53,7 +53,7 @@ function loadRuntime(dir) {
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
return sandbox.spine;
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
}
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
@@ -157,13 +157,9 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
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);
throw new Error('骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
+1 -21
View File
@@ -97,13 +97,12 @@ function detectVersion(filePath) {
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.x 只有 3.8 的 JS 构建包含二进制解析器,因此二进制使用 3.8 运行时
* - 3.6/3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/
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';
@@ -412,7 +411,6 @@ function resolveRealSource(filePath) {
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const preferCompatibleJson = !!options.preferCompatibleJson;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
@@ -518,24 +516,6 @@ function discoverAssets(filePath, options) {
}
}
// Spine 3.6/3.7 的官方 JS 运行时不能读取对应二进制格式。若同批导入中
// 同时带有 JSON,则 atlas 和 skel 条目都用该 JSON 渲染真实缩略图。
if (preferCompatibleJson && skeleton && isSkeletonFile(skeleton.name)) {
let selectedVersion = null;
try { selectedVersion = detectVersion(skeleton.fullPath); } catch (e) { /* ignore */ }
const isOldBinary = /\.skel(?:\.bytes)?$/i.test(skeleton.name) && selectedVersion && parseFloat(selectedVersion) < 3.8;
if (isOldBinary) {
const stem = normalizeStem(skeleton.name);
const jsonCands = realSkeletonFiles.filter((f) =>
f.name.toLowerCase().endsWith('.json') &&
normalizeStem(f.name) === stem &&
familyOfSkel(f) === familyOfSkel(skeleton) &&
(!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= allowedImportDelta)
);
const jsonFallback = selectBest(jsonCands, skeleton.fullPath);
if (jsonFallback) skeleton = jsonFallback;
}
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// Download only test assets, pinned to the official 3.6 and 3.7 branch commits.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
const revisions = { '3.6': '654c20e5b0e523040b6366bbd1042510d2645134', '3.7': '9639bcc81722d7178fd9d1cdc1a3d55a4c91f989' };
for (const [version, revision] of Object.entries(revisions)) {
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor']) {
const directory = path.join(destination, version, name);
fs.mkdirSync(directory, { recursive: true });
for (const file of [name + '-pro.json', name + '-pro.skel', name + '.atlas']) {
const url = `https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/examples/${name}/export/${file}`;
execFileSync('curl', ['-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
}
console.log(version + ' / ' + name);
}
}
+177
View File
@@ -0,0 +1,177 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { decode, install } = require('../js/spine-legacy-binary');
const utils = require('../js/spine-utils');
const clipping = require('../js/spine-canvas-clipping');
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
const names = ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor'];
let poses = 0;
let animations = 0;
function near(actual, expected, tolerance, label) {
assert(Number.isFinite(actual) && Number.isFinite(expected) && Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} != ${expected} (tolerance ${tolerance})`);
}
function arrayNear(actual, expected, tolerance, label) {
assert.equal(actual.length, expected.length, label + ' length');
for (let i = 0; i < actual.length; i++) near(actual[i], expected[i], tolerance, label + '[' + i + ']');
}
function runtime(version) {
const context = { console };
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
return install(context.spine, version);
}
for (const version of ['3.6', '3.7']) {
const spine = runtime(version);
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
for (const name of names) {
const directory = path.join(fixtures, version, name);
const bytes = fs.readFileSync(path.join(directory, name + '-pro.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, name + '-pro.json'), 'utf8'));
const decoded = decode(bytes, version);
assert.equal(decoded.skeleton.hash, json.skeleton.hash);
assert.equal(decoded.skeleton.spine, json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, name + '-pro.skel')), json.skeleton.spine);
// Region geometry uses a real official atlas; no native Canvas dependency
// is needed to verify animation poses and renderer triangle output.
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, name + '.atlas'), 'utf8'), () => texture);
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
// In the pinned 3.6 raptor exports, the same-hash JSON puts the stepped
// IK curve on frame 0, while the SKEL stores it on frame 1. Assert the
// exact discrepancy, then align only this JSON comparison oracle.
if (version === '3.6' && name === 'raptor') {
const binIK = decoded.animations['gun-holster'].ik['front-arm-goal'];
const jsonIK = json.animations['gun-holster'].ik['front-arm-goal'];
assert.equal(binIK[0].curve, undefined); assert.equal(binIK[1].curve, 'stepped');
assert.equal(jsonIK[0].curve, 'stepped'); assert.equal(jsonIK[1].curve, undefined);
delete jsonIK[0].curve; jsonIK[1].curve = 'stepped';
}
for (const scale of [1, 0.5]) {
const binaryLoader = new spine.SkeletonBinary(attachmentLoader); binaryLoader.scale = scale;
const jsonLoader = new spine.SkeletonJson(attachmentLoader); jsonLoader.scale = scale;
const data = binaryLoader.readSkeletonData(bytes), expected = jsonLoader.readSkeletonData(json);
assert.deepEqual(Array.from(data.bones, b => b.name), Array.from(expected.bones, b => b.name));
assert.deepEqual(Array.from(data.slots, s => s.name), Array.from(expected.slots, s => s.name));
assert.deepEqual(Array.from(data.skins, s => s.name), Array.from(expected.skins, s => s.name));
assert.deepEqual(Array.from(data.animations, a => a.name), Array.from(expected.animations, a => a.name));
for (let i = 0; i < data.bones.length; i++) {
for (const k of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'length'])
near(data.bones[i][k], expected.bones[i][k], 0.0052, name + '/bone/' + k);
}
for (const skin of data.skins) {
const reference = expected.findSkin(skin.name);
for (const slot of Object.keys(skin.attachments)) {
for (const [key, a] of Object.entries(skin.attachments[slot])) {
const b = reference.getAttachment(Number(slot), key);
assert(b, key); assert.equal(a.constructor.name, b.constructor.name);
for (const field of ['vertices', 'regionUVs', 'triangles', 'bones', 'lengths']) {
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : 0.0052, name + '/' + key + '/' + field);
}
}
}
}
for (const animation of data.animations) {
const refAnimation = expected.findAnimation(animation.name);
near(animation.duration, refAnimation.duration, 0.00006, 'duration');
assert.equal(animation.timelines.length, refAnimation.timelines.length);
for (let i = 0; i < animation.timelines.length; i++) {
const a = animation.timelines[i], b = refAnimation.timelines[i];
assert.equal(a.constructor.name, b.constructor.name);
arrayNear(a.frames, b.frames, 0.0052, name + '/' + animation.name + '/frames');
if (a.curves) arrayNear(a.curves, b.curves, 0.0052, name + '/' + animation.name + '/curves');
if (a.attachmentNames) assert.deepEqual(Array.from(a.attachmentNames), Array.from(b.attachmentNames));
if (a.frameVertices) for (let f = 0; f < a.frameVertices.length; f++) arrayNear(a.frameVertices[f], b.frameVertices[f], 0.0104, 'deform');
}
animations++;
// Avoid exactly half-duration: rounding JSON times can put samples
// on opposite sides of an attachment keyframe (eg spineboy jump).
for (const fraction of [0, 0.13, 0.473, 0.79, 1]) for (const skin of data.skins) {
const pair = [data, expected].map(d => {
const sk = new spine.Skeleton(d); sk.setSkinByName(skin.name); sk.setToSetupPose();
const state = new spine.AnimationState(new spine.AnimationStateData(d));
state.setAnimation(0, animation.name, false); state.update(d.findAnimation(animation.name).duration * fraction);
state.apply(sk); sk.updateWorldTransform(); return sk;
});
assert.deepEqual(Array.from(pair[0].drawOrder, s => s.data.name), Array.from(pair[1].drawOrder, s => s.data.name));
assert.deepEqual(Array.from(pair[0].slots, s => s.getAttachment()?.name), Array.from(pair[1].slots, s => s.getAttachment()?.name));
for (let i = 0; i < pair[0].bones.length; i++) for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY']) {
// Quantized JSON weights/curves compound along bone chains.
near(pair[0].bones[i][field], pair[1].bones[i][field], 2.1, name + '/' + animation.name + '/' + field);
}
const renderer = clipping.createRenderer(spine, { globalAlpha: 1 }, version);
let triangles = 0;
renderer.drawTriangle = (_image, ...coordinates) => { assert(coordinates.every(Number.isFinite)); triangles++; };
renderer.draw(pair[0]); assert(triangles > 0, name + ' visible geometry'); poses++;
}
}
}
// A damaged file must terminate with an error, never be silently accepted.
for (const end of [0, 1, 12, Math.floor(bytes.length / 2), bytes.length - 1]) assert.throws(() => decode(bytes.subarray(0, end), version));
assert.throws(() => decode(Buffer.concat([bytes, Buffer.from([0])]), version), /尾部/);
assert.throws(() => decode(bytes, version === '3.6' ? '3.7' : '3.6'), /不一致/);
const wrapped = Buffer.concat([Buffer.from([9, 8, 7]), bytes, Buffer.from([6])]);
assert.equal(decode(wrapped.subarray(3, -1), version).skeleton.hash, json.skeleton.hash);
console.log('PASS ' + version + ' / ' + name);
}
}
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) assert.equal(utils.chooseRuntimeDir(version + '.1', true), version);
// Small hand-authored wire fixtures cover fields absent from the official
// example set: point attachments, audio events, UTF-16 surrogate names, signed
// event integers and the nonessential=false header. Values are intentionally
// distinct so a field-order mistake cannot accidentally pass.
function edgeFixture(version, nonessential) {
const chunks = [];
const byte = n => chunks.push(Buffer.from([n]));
const int = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); chunks.push(b); };
const float = n => { const b = Buffer.alloc(4); b.writeFloatBE(n); chunks.push(b); };
const variable = n => { n >>>= 0; while (n >= 128) { byte((n & 127) | 128); n >>>= 7; } byte(n); };
const string = s => {
if (s === null) return variable(0);
const out = [];
for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c < 128) out.push(c); else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63); else out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63); }
variable(out.length + 1); chunks.push(Buffer.from(out));
};
const v37 = version === '3.7';
string('edge'); string(version + '.90'); float(100); float(200); byte(nonessential ? 1 : 0);
if (nonessential) { float(24); string('图像/'); if (v37) string('声音/'); }
variable(1); string('根😀'); // root bone
[0, 3, 4, 1, 1, 0, 0, 10].forEach(float); variable(0); if (nonessential) int(-1);
variable(1); string('slot'); variable(0); int(-1); int(-1); string('point'); variable(0);
variable(1); string('ik'); variable(0); variable(1); variable(0); variable(0); float(0.25); byte(255);
if (v37) { byte(1); byte(0); byte(1); }
variable(0); variable(0); // transform / path constraints
variable(1); variable(0); variable(1); string('point'); string(null); byte(5);
float(35); float(7); float(9); if (nonessential) int(-1);
variable(0); // additional skins
variable(1); string('事件🔔'); variable(245); float(1.25); string('默认');
if (v37) { string('声音.wav'); float(0.75); float(-0.5); }
variable(1); string('anim'); variable(0); variable(0); // slot / bone timelines
variable(1); variable(0); variable(1); float(0.25); float(0.6); byte(255); if (v37) { byte(0); byte(1); }
variable(0); variable(0); variable(0); variable(0); // transform / path / deform / draw order
variable(1); float(0.5); variable(0); variable(641); float(2.5); byte(0);
if (v37) { float(0.4); float(0.2); }
return Buffer.concat(chunks);
}
for (const version of ['3.6', '3.7']) for (const nonessential of [false, true]) {
const bytes = edgeFixture(version, nonessential), d = decode(bytes, version);
assert.equal(d.bones[0].name, '根😀'); assert.equal(d.events['事件🔔'].int, -123);
assert.equal(d.animations.anim.events[0].int, -321); assert.equal(d.animations.anim.events[0].string, '默认');
assert.equal(d.skins.default.slot.point.rotation, 35);
assert.equal(d.skeleton.fps, nonessential ? 24 : undefined);
if (version === '3.7') {
assert.equal(d.ik[0].compress, true); assert.equal(d.ik[0].uniform, true);
assert.equal(d.animations.anim.ik.ik[0].stretch, true);
assert.equal(d.events['事件🔔'].audio, '声音.wav'); near(d.animations.anim.events[0].volume, 0.4, 1e-6, 'audio volume');
}
const spine = runtime(version);
const loader = new spine.SkeletonBinary({ newPointAttachment: (_skin, name) => new spine.PointAttachment(name) });
const data = loader.readSkeletonData(bytes);
assert.equal(data.bones[0].name, '根😀'); assert.equal(data.events[0].intValue, -123);
assert.equal(data.animations[0].duration, 0.5);
}
console.log(`PASS: 14 official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);
+2 -17
View File
@@ -22,7 +22,7 @@ const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v10';
const VERSION = 'v11';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -65,7 +65,6 @@ async function runDiscovery(src, retries, delay) {
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
preferCompatibleJson: true,
allowCrossBatch: false,
});
const signature = assetSignature(assets);
@@ -180,17 +179,6 @@ module.exports = async ({ src, dest, item }) => {
let version = utils.detectVersion(assets.skeletonPath);
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
// 3.6/3.7 二进制没有对应解析器。若有同批 JSON,资源发现阶段已自动切换;
// 走到这里说明没有 JSON,直接显示 atlas 实际贴图,避免无意义地重复解析。
if (assets.isBinary && version && parseFloat(version) < 3.8) {
if (await writeAtlasFallback(assets, dest)) {
log('旧版二进制无 JSON,已使用 atlas 贴图缩略图: ' + src);
await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
}
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
await refreshRelatedThumbnails(assets, dest, item && item.id);
@@ -356,7 +344,6 @@ function scheduleBackgroundReconcile(src) {
await sleep(delay);
const assets = utils.discoverAssets(src, {
strictStem: true,
preferCompatibleJson: true,
allowCrossBatch: true,
});
const signature = assetSignature(assets);
@@ -370,9 +357,7 @@ function scheduleBackgroundReconcile(src) {
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
let wrote = false;
if (assets.isBinary && version && parseFloat(version) < 3.8) {
wrote = await writeAtlasFallback(assets, tempPng);
} else if (runtimeDir) {
if (runtimeDir) {
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(tempPng, png);
+5 -7
View File
@@ -272,7 +272,10 @@
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '../lib/' + dir + '/spine-canvas.js';
s.onload = () => resolve();
s.onload = () => {
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
catch (e) { reject(e); }
};
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
document.head.appendChild(s);
});
@@ -859,13 +862,8 @@
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`)后再导入预览。'
: '未能读取到有效的骨骼数据。');
showError('骨骼数据为空', '未能读取到有效的骨骼数据。');
return;
}