diff --git a/.gitignore b/.gitignore index 97b1be8..11e4712 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ Thumbs.db # Temp test artifacts /tmp/ *.png.d.ts +tests/.fixtures/ diff --git a/LICENSES/Spine-Legacy-Binary-License.txt b/LICENSES/Spine-Legacy-Binary-License.txt new file mode 100644 index 0000000..1974c99 --- /dev/null +++ b/LICENSES/Spine-Legacy-Binary-License.txt @@ -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. + *****************************************************************************/ + diff --git a/README.md b/README.md index 2c91d48..c9ad3d5 100644 --- a/README.md +++ b/README.md @@ -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`。 --- diff --git a/js/spine-legacy-binary.js b/js/spine-legacy-binary.js new file mode 100644 index 0000000..4df2d1b --- /dev/null +++ b/js/spine-legacy-binary.js @@ -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 }; diff --git a/js/spine-render.js b/js/spine-render.js index e3ac196..9b7bea6 100644 --- a/js/spine-render.js +++ b/js/spine-render.js @@ -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); diff --git a/js/spine-utils.js b/js/spine-utils.js index 11a4d2e..cec07e7 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -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; diff --git a/tests/fetch-legacy-fixtures.js b/tests/fetch-legacy-fixtures.js new file mode 100644 index 0000000..14ede3e --- /dev/null +++ b/tests/fetch-legacy-fixtures.js @@ -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); + } +} + diff --git a/tests/legacy-binary.test.js b/tests/legacy-binary.test.js new file mode 100644 index 0000000..d103967 --- /dev/null +++ b/tests/legacy-binary.test.js @@ -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`); diff --git a/thumbnail/spine.js b/thumbnail/spine.js index da8091e..90865fb 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -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); diff --git a/viewer/spine.html b/viewer/spine.html index 539505e..4153293 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -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; }